diff --git a/Dockerfile b/Dockerfile index d0f4f03e..8992730b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,13 +19,16 @@ COPY tools ./tools COPY hack ./hack # Build the executable binary (CGO_ENABLED=0 means static linking) -RUN mkdir out && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(hack/get-ldflags.sh)" -o out ./cmd/pinniped-server/... +RUN mkdir out \ + && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(hack/get-ldflags.sh)" -o out ./cmd/pinniped-server/... \ + && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o out ./cmd/local-user-authenticator/... # Use a runtime image based on Debian slim FROM debian:10.5-slim -# Copy the binary from the build-env stage +# Copy the binaries from the build-env stage COPY --from=build-env /work/out/pinniped-server /usr/local/bin/pinniped-server +COPY --from=build-env /work/out/local-user-authenticator /usr/local/bin/local-user-authenticator # Document the port EXPOSE 443 diff --git a/cmd/local-user-authenticator/main.go b/cmd/local-user-authenticator/main.go new file mode 100644 index 00000000..4d66ea4e --- /dev/null +++ b/cmd/local-user-authenticator/main.go @@ -0,0 +1,357 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +// Package main provides a authentication webhook program. +// +// This webhook is meant to be used in demo settings to play around with +// Pinniped. As well, it can come in handy in integration tests. +// +// This webhook is NOT meant for use in production systems. +package main + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/csv" + "encoding/json" + "fmt" + "mime" + "net" + "net/http" + "os" + "os/signal" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" + authenticationv1 "k8s.io/api/authentication/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + kubeinformers "k8s.io/client-go/informers" + corev1informers "k8s.io/client-go/informers/core/v1" + "k8s.io/client-go/kubernetes" + restclient "k8s.io/client-go/rest" + "k8s.io/klog/v2" + + "github.com/suzerain-io/pinniped/internal/constable" + "github.com/suzerain-io/pinniped/internal/controller/apicerts" + "github.com/suzerain-io/pinniped/internal/controllerlib" + "github.com/suzerain-io/pinniped/internal/provider" +) + +const ( + // This string must match the name of the Namespace declared in the deployment yaml. + namespace = "local-user-authenticator" + // This string must match the name of the Service declared in the deployment yaml. + serviceName = "local-user-authenticator" + + unauthenticatedResponse = `{"apiVersion":"authentication.k8s.io/v1beta1","kind":"TokenReview","status":{"authenticated":false}}` + authenticatedResponseTemplate = `{"apiVersion":"authentication.k8s.io/v1beta1","kind":"TokenReview","status":{"authenticated":true,"user":{"username":"%s","uid":"%s","groups":%s}}}` + + singletonWorker = 1 + defaultResyncInterval = 3 * time.Minute + + invalidRequest = constable.Error("invalid request") +) + +type webhook struct { + certProvider provider.DynamicTLSServingCertProvider + secretInformer corev1informers.SecretInformer +} + +func newWebhook( + certProvider provider.DynamicTLSServingCertProvider, + secretInformer corev1informers.SecretInformer, +) *webhook { + return &webhook{ + certProvider: certProvider, + secretInformer: secretInformer, + } +} + +// start runs the webhook in a separate goroutine and returns whether or not the +// webhook was started successfully. +func (w *webhook) start(ctx context.Context, l net.Listener) error { + server := http.Server{ + Handler: w, + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS13, + GetCertificate: func(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { + certPEM, keyPEM := w.certProvider.CurrentCertKeyContent() + cert, err := tls.X509KeyPair(certPEM, keyPEM) + return &cert, err + }, + }, + } + + errCh := make(chan error) + go func() { + // Per ListenAndServeTLS doc, the {cert,key}File parameters can be empty + // since we want to use the certs from http.Server.TLSConfig. + errCh <- server.ServeTLS(l, "", "") + }() + + go func() { + select { + case err := <-errCh: + klog.InfoS("server exited", "err", err) + case <-ctx.Done(): + klog.InfoS("server context cancelled", "err", ctx.Err()) + if err := server.Shutdown(context.Background()); err != nil { + klog.InfoS("server shutdown failed", "err", err) + } + } + }() + + return nil +} + +func (w *webhook) ServeHTTP(rsp http.ResponseWriter, req *http.Request) { + defer req.Body.Close() + + username, password, err := getUsernameAndPasswordFromRequest(rsp, req) + if err != nil { + return + } + + secret, err := w.secretInformer.Lister().Secrets(namespace).Get(username) + notFound := k8serrors.IsNotFound(err) + if err != nil && !notFound { + klog.InfoS("could not get secret", "err", err) + rsp.WriteHeader(http.StatusInternalServerError) + return + } + + if notFound { + klog.InfoS("user not found") + respondWithUnauthenticated(rsp) + return + } + + passwordMatches := bcrypt.CompareHashAndPassword( + secret.Data["passwordHash"], + []byte(password), + ) == nil + if !passwordMatches { + klog.InfoS("invalid password in request") + respondWithUnauthenticated(rsp) + return + } + + groups := []string{} + groupsBuf := bytes.NewBuffer(secret.Data["groups"]) + if groupsBuf.Len() > 0 { + groupsCSVReader := csv.NewReader(groupsBuf) + groups, err = groupsCSVReader.Read() + if err != nil { + klog.InfoS("could not read groups", "err", err) + rsp.WriteHeader(http.StatusInternalServerError) + return + } + trimLeadingAndTrailingWhitespace(groups) + } + + klog.InfoS("successful authentication") + respondWithAuthenticated(rsp, secret.ObjectMeta.Name, string(secret.UID), groups) +} + +func getUsernameAndPasswordFromRequest(rsp http.ResponseWriter, req *http.Request) (string, string, error) { + if req.URL.Path != "/authenticate" { + klog.InfoS("received request path other than /authenticate", "path", req.URL.Path) + rsp.WriteHeader(http.StatusNotFound) + return "", "", invalidRequest + } + + if req.Method != http.MethodPost { + klog.InfoS("received request method other than post", "method", req.Method) + rsp.WriteHeader(http.StatusMethodNotAllowed) + return "", "", invalidRequest + } + + if !headerContains(req, "Content-Type", "application/json") { + klog.InfoS("content type is not application/json", "Content-Type", req.Header.Values("Content-Type")) + rsp.WriteHeader(http.StatusUnsupportedMediaType) + return "", "", invalidRequest + } + + if !headerContains(req, "Accept", "application/json") && + !headerContains(req, "Accept", "application/*") && + !headerContains(req, "Accept", "*/*") { + klog.InfoS("client does not accept application/json", "Accept", req.Header.Values("Accept")) + rsp.WriteHeader(http.StatusUnsupportedMediaType) + return "", "", invalidRequest + } + + var body authenticationv1.TokenReview + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + klog.InfoS("failed to decode body", "err", err) + rsp.WriteHeader(http.StatusBadRequest) + return "", "", invalidRequest + } + + tokenSegments := strings.SplitN(body.Spec.Token, ":", 2) + if len(tokenSegments) != 2 { + klog.InfoS("bad token format in request") + rsp.WriteHeader(http.StatusBadRequest) + return "", "", invalidRequest + } + + return tokenSegments[0], tokenSegments[1], nil +} + +func headerContains(req *http.Request, headerName, s string) bool { + headerValues := req.Header.Values(headerName) + for i := range headerValues { + mimeTypes := strings.Split(headerValues[i], ",") + for _, mimeType := range mimeTypes { + mediaType, _, _ := mime.ParseMediaType(mimeType) + if mediaType == s { + return true + } + } + } + return false +} + +func trimLeadingAndTrailingWhitespace(ss []string) { + for i := range ss { + ss[i] = strings.TrimSpace(ss[i]) + } +} + +func respondWithUnauthenticated(rsp http.ResponseWriter) { + rsp.Header().Add("Content-Type", "application/json") + _, _ = rsp.Write([]byte(unauthenticatedResponse)) +} + +func respondWithAuthenticated( + rsp http.ResponseWriter, + username, uid string, + groups []string, +) { + rsp.Header().Add("Content-Type", "application/json") + groupsJSONBytes, err := json.Marshal(groups) + if err != nil { + klog.InfoS("could not encode response", "err", err) + rsp.WriteHeader(http.StatusInternalServerError) + return + } + jsonBody := fmt.Sprintf(authenticatedResponseTemplate, username, uid, groupsJSONBytes) + _, _ = rsp.Write([]byte(jsonBody)) +} + +func newK8sClient() (kubernetes.Interface, error) { + kubeConfig, err := restclient.InClusterConfig() + if err != nil { + return nil, fmt.Errorf("could not load in-cluster configuration: %w", err) + } + + // Connect to the core Kubernetes API. + kubeClient, err := kubernetes.NewForConfig(kubeConfig) + if err != nil { + return nil, fmt.Errorf("could not load in-cluster configuration: %w", err) + } + + return kubeClient, nil +} + +func startControllers( + ctx context.Context, + dynamicCertProvider provider.DynamicTLSServingCertProvider, + kubeClient kubernetes.Interface, + kubeInformers kubeinformers.SharedInformerFactory, +) { + aVeryLongTime := time.Hour * 24 * 365 * 100 + + // Create controller manager. + controllerManager := controllerlib. + NewManager(). + WithController( + apicerts.NewCertsManagerController( + namespace, + kubeClient, + kubeInformers.Core().V1().Secrets(), + controllerlib.WithInformer, + controllerlib.WithInitialEvent, + aVeryLongTime, + "local-user-authenticator CA", + serviceName, + ), + singletonWorker, + ). + WithController( + apicerts.NewCertsObserverController( + namespace, + dynamicCertProvider, + kubeInformers.Core().V1().Secrets(), + controllerlib.WithInformer, + ), + singletonWorker, + ) + + kubeInformers.Start(ctx.Done()) + + go controllerManager.Start(ctx) +} + +func startWebhook( + ctx context.Context, + l net.Listener, + dynamicCertProvider provider.DynamicTLSServingCertProvider, + secretInformer corev1informers.SecretInformer, +) error { + return newWebhook(dynamicCertProvider, secretInformer).start(ctx, l) +} + +func waitForSignal() os.Signal { + signalCh := make(chan os.Signal, 1) + signal.Notify(signalCh, os.Interrupt) + return <-signalCh +} + +func run() error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + kubeClient, err := newK8sClient() + if err != nil { + return fmt.Errorf("cannot create k8s client: %w", err) + } + + kubeInformers := kubeinformers.NewSharedInformerFactoryWithOptions( + kubeClient, + defaultResyncInterval, + kubeinformers.WithNamespace(namespace), + ) + + dynamicCertProvider := provider.NewDynamicTLSServingCertProvider() + + startControllers(ctx, dynamicCertProvider, kubeClient, kubeInformers) + klog.InfoS("controllers are ready") + + //nolint: gosec + l, err := net.Listen("tcp", ":443") + if err != nil { + return fmt.Errorf("cannot create listener: %w", err) + } + defer l.Close() + + err = startWebhook(ctx, l, dynamicCertProvider, kubeInformers.Core().V1().Secrets()) + if err != nil { + return fmt.Errorf("cannot start webhook: %w", err) + } + klog.InfoS("webhook is ready", "address", l.Addr().String()) + + gotSignal := waitForSignal() + klog.InfoS("webhook exiting", "signal", gotSignal) + + return nil +} + +func main() { + if err := run(); err != nil { + klog.Fatal(err) + } +} diff --git a/cmd/local-user-authenticator/main_test.go b/cmd/local-user-authenticator/main_test.go new file mode 100644 index 00000000..837b3c2a --- /dev/null +++ b/cmd/local-user-authenticator/main_test.go @@ -0,0 +1,494 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package main + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net" + "net/http" + "net/url" + "reflect" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + authenticationv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + kubeinformers "k8s.io/client-go/informers" + corev1informers "k8s.io/client-go/informers/core/v1" + "k8s.io/client-go/kubernetes" + kubernetesfake "k8s.io/client-go/kubernetes/fake" + + "github.com/suzerain-io/pinniped/internal/certauthority" + "github.com/suzerain-io/pinniped/internal/provider" +) + +func TestWebhook(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + user, otherUser, colonUser, noGroupUser, oneGroupUser, passwordUndefinedUser, emptyPasswordUser, underfinedGroupsUser := + "some-user", "other-user", "colon-user", "no-group-user", "one-group-user", "password-undefined-user", "empty-password-user", "undefined-groups-user" + uid, otherUID, colonUID, noGroupUID, oneGroupUID, passwordUndefinedUID, emptyPasswordUID, underfinedGroupsUID := + "some-uid", "other-uid", "colon-uid", "no-group-uid", "one-group-uid", "password-undefined-uid", "empty-password-uid", "undefined-groups-uid" + password, otherPassword, colonPassword, noGroupPassword, oneGroupPassword, undefinedGroupsPassword := + "some-password", "other-password", "some-:-password", "no-group-password", "one-group-password", "undefined-groups-password" + + group0, group1 := "some-group-0", "some-group-1" + groups := group0 + " , " + group1 + + kubeClient := kubernetesfake.NewSimpleClientset() + addSecretToFakeClientTracker(t, kubeClient, user, uid, password, groups) + addSecretToFakeClientTracker(t, kubeClient, otherUser, otherUID, otherPassword, groups) + addSecretToFakeClientTracker(t, kubeClient, colonUser, colonUID, colonPassword, groups) + addSecretToFakeClientTracker(t, kubeClient, noGroupUser, noGroupUID, noGroupPassword, "") + addSecretToFakeClientTracker(t, kubeClient, oneGroupUser, oneGroupUID, oneGroupPassword, group0) + addSecretToFakeClientTracker(t, kubeClient, emptyPasswordUser, emptyPasswordUID, "", groups) + + require.NoError(t, kubeClient.Tracker().Add(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + UID: types.UID(passwordUndefinedUID), + Name: passwordUndefinedUser, + Namespace: "local-user-authenticator", + }, + Data: map[string][]byte{ + "groups": []byte(groups), + }, + })) + + undefinedGroupsUserPasswordHash, err := bcrypt.GenerateFromPassword([]byte(undefinedGroupsPassword), bcrypt.MinCost) + require.NoError(t, err) + + require.NoError(t, kubeClient.Tracker().Add(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + UID: types.UID(underfinedGroupsUID), + Name: underfinedGroupsUser, + Namespace: "local-user-authenticator", + }, + Data: map[string][]byte{ + "passwordHash": undefinedGroupsUserPasswordHash, + }, + })) + + secretInformer := createSecretInformer(t, kubeClient) + + certProvider, caBundle, serverName := newCertProvider(t) + w := newWebhook(certProvider, secretInformer) + + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer l.Close() + require.NoError(t, w.start(ctx, l)) + + client := newClient(caBundle, serverName) + + goodURL := fmt.Sprintf("https://%s/authenticate", l.Addr().String()) + goodRequestHeaders := map[string][]string{ + "Content-Type": {"application/json; charset=UTF-8"}, + "Accept": {"application/json, */*"}, + } + + tests := []struct { + name string + url string + method string + headers map[string][]string + body func() (io.ReadCloser, error) + + wantStatus int + wantHeaders map[string][]string + wantBody *string + }{ + { + name: "success for a user who belongs to multiple groups", + url: goodURL, + method: http.MethodPost, + headers: goodRequestHeaders, + body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + password) }, + wantStatus: http.StatusOK, + wantHeaders: map[string][]string{"Content-Type": {"application/json"}}, + wantBody: authenticatedResponseJSON(user, uid, []string{group0, group1}), + }, + { + name: "success for a user who belongs to one groups", + url: goodURL, + method: http.MethodPost, + headers: goodRequestHeaders, + body: func() (io.ReadCloser, error) { return newTokenReviewBody(oneGroupUser + ":" + oneGroupPassword) }, + wantStatus: http.StatusOK, + wantHeaders: map[string][]string{"Content-Type": {"application/json"}}, + wantBody: authenticatedResponseJSON(oneGroupUser, oneGroupUID, []string{group0}), + }, + { + name: "success for a user who belongs to no groups", + url: goodURL, + method: http.MethodPost, + headers: goodRequestHeaders, + body: func() (io.ReadCloser, error) { return newTokenReviewBody(noGroupUser + ":" + noGroupPassword) }, + wantStatus: http.StatusOK, + wantHeaders: map[string][]string{"Content-Type": {"application/json"}}, + wantBody: authenticatedResponseJSON(noGroupUser, noGroupUID, []string{}), + }, + { + name: "wrong username for password", + url: goodURL, + method: http.MethodPost, + headers: goodRequestHeaders, + body: func() (io.ReadCloser, error) { return newTokenReviewBody(otherUser + ":" + password) }, + wantStatus: http.StatusOK, + wantHeaders: map[string][]string{"Content-Type": {"application/json"}}, + wantBody: unauthenticatedResponseJSON(), + }, + { + name: "when a user has no password hash in the secret", + url: goodURL, + method: http.MethodPost, + headers: goodRequestHeaders, + body: func() (io.ReadCloser, error) { return newTokenReviewBody(passwordUndefinedUser + ":foo") }, + wantStatus: http.StatusOK, + wantHeaders: map[string][]string{"Content-Type": {"application/json"}}, + wantBody: unauthenticatedResponseJSON(), + }, + { + name: "success for a user has no groups defined in the secret", + url: goodURL, + method: http.MethodPost, + headers: goodRequestHeaders, + body: func() (io.ReadCloser, error) { + return newTokenReviewBody(underfinedGroupsUser + ":" + undefinedGroupsPassword) + }, + wantStatus: http.StatusOK, + wantHeaders: map[string][]string{"Content-Type": {"application/json"}}, + wantBody: authenticatedResponseJSON(underfinedGroupsUser, underfinedGroupsUID, []string{}), + }, + { + name: "when a user has empty string as their password", + url: goodURL, + method: http.MethodPost, + headers: goodRequestHeaders, + body: func() (io.ReadCloser, error) { return newTokenReviewBody(passwordUndefinedUser + ":foo") }, + wantStatus: http.StatusOK, + wantHeaders: map[string][]string{"Content-Type": {"application/json"}}, + wantBody: unauthenticatedResponseJSON(), + }, + { + name: "wrong password for username", + url: goodURL, + method: http.MethodPost, + headers: goodRequestHeaders, + body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + otherPassword) }, + wantStatus: http.StatusOK, + wantHeaders: map[string][]string{"Content-Type": {"application/json"}}, + wantBody: unauthenticatedResponseJSON(), + }, + { + name: "non-existent password for username", + url: goodURL, + method: http.MethodPost, + headers: goodRequestHeaders, + body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + "some-non-existent-password") }, + wantStatus: http.StatusOK, + wantHeaders: map[string][]string{"Content-Type": {"application/json"}}, + wantBody: unauthenticatedResponseJSON(), + }, + { + name: "non-existent username", + url: goodURL, + method: http.MethodPost, + headers: goodRequestHeaders, + body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-non-existent-user" + ":" + password) }, + wantStatus: http.StatusOK, + wantHeaders: map[string][]string{"Content-Type": {"application/json"}}, + wantBody: unauthenticatedResponseJSON(), + }, + { + name: "bad token format (missing colon)", + url: goodURL, + method: http.MethodPost, + headers: goodRequestHeaders, + body: func() (io.ReadCloser, error) { return newTokenReviewBody(user) }, + wantStatus: http.StatusBadRequest, + }, + { + name: "password contains colon", + url: goodURL, + method: http.MethodPost, + headers: goodRequestHeaders, + body: func() (io.ReadCloser, error) { return newTokenReviewBody(colonUser + ":" + colonPassword) }, + wantStatus: http.StatusOK, + wantHeaders: map[string][]string{"Content-Type": {"application/json"}}, + wantBody: authenticatedResponseJSON(colonUser, colonUID, []string{group0, group1}), + }, + { + name: "bad path", + url: fmt.Sprintf("https://%s/tuna", l.Addr().String()), + method: http.MethodPost, + headers: goodRequestHeaders, + body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-token") }, + wantStatus: http.StatusNotFound, + }, + { + name: "bad method", + url: goodURL, + method: http.MethodGet, + headers: goodRequestHeaders, + body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-token") }, + wantStatus: http.StatusMethodNotAllowed, + }, + { + name: "bad content type", + url: goodURL, + method: http.MethodPost, + headers: map[string][]string{ + "Content-Type": {"application/xml"}, + "Accept": {"application/json"}, + }, + body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-token") }, + wantStatus: http.StatusUnsupportedMediaType, + }, + { + name: "bad accept", + url: goodURL, + method: http.MethodPost, + headers: map[string][]string{ + "Content-Type": {"application/json"}, + "Accept": {"application/xml"}, + }, + body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-token") }, + wantStatus: http.StatusUnsupportedMediaType, + }, + { + name: "success when there are multiple accepts and one of them is json", + url: goodURL, + method: http.MethodPost, + headers: map[string][]string{ + "Content-Type": {"application/json"}, + "Accept": {"something/else, application/xml, application/json"}, + }, + body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + password) }, + wantStatus: http.StatusOK, + wantHeaders: map[string][]string{"Content-Type": {"application/json"}}, + wantBody: authenticatedResponseJSON(user, uid, []string{group0, group1}), + }, + { + name: "success when there are multiple accepts and one of them is */*", + url: goodURL, + method: http.MethodPost, + headers: map[string][]string{ + "Content-Type": {"application/json"}, + "Accept": {"something/else, */*, application/foo"}, + }, + body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + password) }, + wantStatus: http.StatusOK, + wantHeaders: map[string][]string{"Content-Type": {"application/json"}}, + wantBody: authenticatedResponseJSON(user, uid, []string{group0, group1}), + }, + { + name: "success when there are multiple accepts and one of them is application/*", + url: goodURL, + method: http.MethodPost, + headers: map[string][]string{ + "Content-Type": {"application/json"}, + "Accept": {"something/else, application/*, application/foo"}, + }, + body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + password) }, + wantStatus: http.StatusOK, + wantHeaders: map[string][]string{"Content-Type": {"application/json"}}, + wantBody: authenticatedResponseJSON(user, uid, []string{group0, group1}), + }, + { + name: "bad body", + url: goodURL, + method: http.MethodPost, + headers: goodRequestHeaders, + body: func() (io.ReadCloser, error) { return ioutil.NopCloser(bytes.NewBuffer([]byte("invalid body"))), nil }, + wantStatus: http.StatusBadRequest, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + parsedURL, err := url.Parse(test.url) + require.NoError(t, err) + + body, err := test.body() + require.NoError(t, err) + + rsp, err := client.Do(&http.Request{ + Method: test.method, + URL: parsedURL, + Header: test.headers, + Body: body, + }) + require.NoError(t, err) + defer rsp.Body.Close() + + require.Equal(t, test.wantStatus, rsp.StatusCode) + + if test.wantHeaders != nil { + for k, v := range test.wantHeaders { + require.Equal(t, v, rsp.Header.Values(k)) + } + } + + responseBody, err := ioutil.ReadAll(rsp.Body) + require.NoError(t, err) + if test.wantBody != nil { + require.NoError(t, err) + require.JSONEq(t, *test.wantBody, string(responseBody)) + } else { + require.Empty(t, responseBody) + } + }) + } +} + +func createSecretInformer(t *testing.T, kubeClient kubernetes.Interface) corev1informers.SecretInformer { + t.Helper() + + kubeInformers := kubeinformers.NewSharedInformerFactory(kubeClient, 0) + + secretInformer := kubeInformers.Core().V1().Secrets() + + // We need to call Informer() on the secretInformer to lazily instantiate the + // informer factory before syncing it. + secretInformer.Informer() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) + defer cancel() + + kubeInformers.Start(ctx.Done()) + + informerTypesSynced := kubeInformers.WaitForCacheSync(ctx.Done()) + require.True(t, informerTypesSynced[reflect.TypeOf(&corev1.Secret{})]) + + return secretInformer +} + +// newClientProvider returns a provider.DynamicTLSServingCertProvider configured +// with valid serving cert, the CA bundle that can be used to verify the serving +// cert, and the server name that can be used to verify the TLS peer. +func newCertProvider(t *testing.T) (provider.DynamicTLSServingCertProvider, []byte, string) { + t.Helper() + + ca, err := certauthority.New(pkix.Name{CommonName: "local-user-authenticator CA"}, time.Hour*24) + require.NoError(t, err) + + serverName := "local-user-authenticator" + cert, err := ca.Issue( + pkix.Name{CommonName: serverName}, + []string{serverName}, + time.Hour*24, + ) + require.NoError(t, err) + + certPEM, keyPEM, err := certauthority.ToPEM(cert) + require.NoError(t, err) + + certProvider := provider.NewDynamicTLSServingCertProvider() + certProvider.Set(certPEM, keyPEM) + + return certProvider, ca.Bundle(), serverName +} + +// newClient creates an http.Client that can be used to make an HTTPS call to a +// service whose serving certs can be verified by the provided CA bundle. +func newClient(caBundle []byte, serverName string) *http.Client { + rootCAs := x509.NewCertPool() + rootCAs.AppendCertsFromPEM(caBundle) + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS13, + RootCAs: rootCAs, + ServerName: serverName, + }, + }, + } +} + +// newTokenReviewBody creates an io.ReadCloser that contains a JSON-encoded +// TokenReview request. +func newTokenReviewBody(token string) (io.ReadCloser, error) { + buf := bytes.NewBuffer([]byte{}) + tr := authenticationv1.TokenReview{ + Spec: authenticationv1.TokenReviewSpec{ + Token: token, + }, + } + err := json.NewEncoder(buf).Encode(&tr) + return ioutil.NopCloser(buf), err +} + +func unauthenticatedResponseJSON() *string { + // Very specific expected result. Avoid using json package so we know exactly what we're asserting. + s := `{ + "apiVersion": "authentication.k8s.io/v1beta1", + "kind": "TokenReview", + "status": { + "authenticated": false + } + }` + return &s +} + +func authenticatedResponseJSON(user, uid string, groups []string) *string { + quotedGroups := make([]string, len(groups)) + for i, group := range groups { + quotedGroups[i] = `"` + group + `"` + } + + // Very specific expected result. Avoid using json package so we know exactly what we're asserting. + authenticatedJSONTemplate := `{ + "apiVersion": "authentication.k8s.io/v1beta1", + "kind": "TokenReview", + "status": { + "authenticated": true, + "user": { + "username": "%s", + "uid": "%s", + "groups": [%s] + } + } + }` + + s := fmt.Sprintf(authenticatedJSONTemplate, user, uid, strings.Join(quotedGroups, ",")) + return &s +} + +func addSecretToFakeClientTracker(t *testing.T, kubeClient *kubernetesfake.Clientset, username, uid, password, groups string) { + passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) + require.NoError(t, err) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + UID: types.UID(uid), + Name: username, + Namespace: "local-user-authenticator", + }, + Data: map[string][]byte{ + "passwordHash": passwordHash, + "groups": []byte(groups), + }, + } + + require.NoError(t, kubeClient.Tracker().Add(secret)) +} diff --git a/deploy-local-user-authenticator/README.md b/deploy-local-user-authenticator/README.md new file mode 100644 index 00000000..a4f4b88f --- /dev/null +++ b/deploy-local-user-authenticator/README.md @@ -0,0 +1,115 @@ +# Deploying `local-user-authenticator` + +## What is `local-user-authenticator`? + +The `local-user-authenticator` app is an identity provider used for integration testing and demos. +If you would like to demo Pinniped, but you don't have a compatible identity provider handy, +you can use Pinniped's `local-user-authenticator` identity provider. Note that this is not recommended for +production use. + +The `local-user-authenticator` is a Kubernetes Deployment which runs a webhook server that implements the Kubernetes +[Webhook Token Authentication interface](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#webhook-token-authentication). + +User accounts can be created and edited dynamically using `kubectl` commands (see below). + +## Tools + +This example deployment uses `ytt` from [Carvel](https://carvel.dev/) to template the YAML files. +Either [install `ytt`](https://get-ytt.io/) or use the [container image from Dockerhub](https://hub.docker.com/r/k14s/image/tags). + +As well, this demo requires a tool capable of generating a `bcrypt` hash in order to interact with +the webhook. The example below uses `htpasswd`, which is installed on most macOS systems, and can be +installed on some Linux systems via the `apache2-utils` package (e.g., `apt-get install +apache2-utils`). + +## Procedure + +1. The configuration options are in [values.yml](values.yaml). Fill in the values in that file, or override those values + using `ytt` command-line options in the command below. +2. In a terminal, cd to this `deploy-local-user-authenticator` directory +3. To generate the final YAML files, run: `ytt --file .` +4. Deploy the generated YAML using your preferred deployment tool, such as `kubectl` or [`kapp`](https://get-kapp.io/). + For example: `ytt --file . | kapp deploy --yes --app local-user-authenticator --diff-changes --file -` + +## Configuring After Installing + +### Create Users + +Use `kubectl` to create, edit, and delete user accounts by creating a `Secret` for each user account in the same +namespace where `local-user-authenticator` is deployed. The name of the `Secret` resource is the username. +Store the user's group membership and `bcrypt` encrypted password as the contents of the `Secret`. +For example, to create a user named `ryan` with the password `password123` +who belongs to the groups `group1` and `group2`, use: + +```bash +kubectl create secret generic ryan \ + --namespace local-user-authenticator \ + --from-literal=groups=group1,group2 \ + --from-literal=passwordHash=$(htpasswd -nbBC 10 x password123 | sed -e "s/^x://") +``` + +### Get the `local-user-authenticator` App's Auto-Generated Certificate Authority Bundle + +Fetch the auto-generated CA bundle for the `local-user-authenticator`'s HTTP TLS endpoint. + +```bash +kubectl get secret api-serving-cert --namespace local-user-authenticator \ + -o jsonpath={.data.caCertificate} \ + | base64 -d \ + | tee /tmp/local-user-authenticator-ca +``` + +### Configuring Pinniped to Use `local-user-authenticator` as an Identity Provider + +When installing Pinniped on the same cluster, configure `local-user-authenticator` as an Identity Provider for Pinniped +using the webhook URL `https://local-user-authenticator.local-user-authenticator.svc/authenticate` +along with the CA bundle fetched by the above command. + +### Optional: Manually Test the Webhook Endpoint + + 1. Start a pod from which you can curl the endpoint from inside the cluster. + + ```bash + kubectl run curlpod --image=curlimages/curl --command -- /bin/sh -c "while true; do echo hi; sleep 120; done" + ``` + + 1. Copy the CA bundle that was fetched above onto the new pod. + + ```bash + kubectl cp /tmp/local-user-authenticator-ca curlpod:/tmp/local-user-authenticator-ca + ``` + + 1. Run a `curl` command to try to authenticate as the user created above. + + ```bash + kubectl -it exec curlpod -- curl https://local-user-authenticator.local-user-authenticator.svc/authenticate \ + --cacert /tmp/local-user-authenticator-ca \ + -H 'Content-Type: application/json' -H 'Accept: application/json' -d ' + { + "apiVersion": "authentication.k8s.io/v1beta1", + "kind": "TokenReview", + "spec": { + "token": "ryan:password123" + } + }' + ``` + + When authentication is successful the above command should return some JSON similar to the following. + Note that the value of `authenticated` is `true` to indicate a successful authentication. + + ```json + {"apiVersion":"authentication.k8s.io/v1beta1","kind":"TokenReview","status":{"authenticated":true,"user":{"username":"ryan","uid":"19c433ec-8f58-44ca-9ef0-2d1081ccb876","groups":["group1","group2"]}}} + ``` + + Trying the above `curl` command again with the wrong username or password in the body of the request + should result in a JSON response which indicates that the authentication failed. + + ```json + {"apiVersion":"authentication.k8s.io/v1beta1","kind":"TokenReview","status":{"authenticated":false}} + ``` + + 1. Remove the curl pod. + + ```bash + kubectl delete pod curlpod + ``` diff --git a/deploy-local-user-authenticator/deployment.yaml b/deploy-local-user-authenticator/deployment.yaml new file mode 100644 index 00000000..aed4257b --- /dev/null +++ b/deploy-local-user-authenticator/deployment.yaml @@ -0,0 +1,63 @@ +#! Copyright 2020 VMware, Inc. +#! SPDX-License-Identifier: Apache-2.0 + +#@ load("@ytt:data", "data") + +--- +apiVersion: v1 +kind: Namespace +metadata: + name: local-user-authenticator + labels: + name: local-user-authenticator +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: local-user-authenticator-service-account + namespace: local-user-authenticator +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: local-user-authenticator + namespace: local-user-authenticator + labels: + app: local-user-authenticator +spec: + replicas: 1 + selector: + matchLabels: + app: local-user-authenticator + template: + metadata: + labels: + app: local-user-authenticator + spec: + serviceAccountName: local-user-authenticator-service-account + containers: + - name: local-user-authenticator + #@ if data.values.image_digest: + image: #@ data.values.image_repo + "@" + data.values.image_digest + #@ else: + image: #@ data.values.image_repo + ":" + data.values.image_tag + #@ end + imagePullPolicy: IfNotPresent + command: #! override the default entrypoint + - /usr/local/bin/local-user-authenticator +--- +apiVersion: v1 +kind: Service +metadata: + name: local-user-authenticator + namespace: local-user-authenticator + labels: + app: local-user-authenticator +spec: + type: ClusterIP + selector: + app: local-user-authenticator + ports: + - protocol: TCP + port: 443 + targetPort: 443 diff --git a/deploy-local-user-authenticator/rbac.yaml b/deploy-local-user-authenticator/rbac.yaml new file mode 100644 index 00000000..27ad1c2f --- /dev/null +++ b/deploy-local-user-authenticator/rbac.yaml @@ -0,0 +1,30 @@ +#! Copyright 2020 VMware, Inc. +#! SPDX-License-Identifier: Apache-2.0 + +#@ load("@ytt:data", "data") + +#! Give permission to various objects within the app's own namespace +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: local-user-authenticator-aggregated-api-server-role + namespace: local-user-authenticator +rules: + - apiGroups: [""] + resources: [secrets] + verbs: [create, get, list, patch, update, watch] +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: local-user-authenticator-aggregated-api-server-role-binding + namespace: local-user-authenticator +subjects: + - kind: ServiceAccount + name: local-user-authenticator-service-account + namespace: local-user-authenticator +roleRef: + kind: Role + name: local-user-authenticator-aggregated-api-server-role + apiGroup: rbac.authorization.k8s.io diff --git a/deploy-local-user-authenticator/values.yaml b/deploy-local-user-authenticator/values.yaml new file mode 100644 index 00000000..d8531a2c --- /dev/null +++ b/deploy-local-user-authenticator/values.yaml @@ -0,0 +1,10 @@ +#! Copyright 2020 VMware, Inc. +#! SPDX-License-Identifier: Apache-2.0 + +#@data/values +--- + +#! Specify either an image_digest or an image_tag. If both are given, only image_digest will be used. +image_repo: #! e.g. registry.example.com/your-project-name/repo-name +image_digest: #! e.g. sha256:f3c4fdfd3ef865d4b97a1fd295d94acc3f0c654c46b6f27ffad5cf80216903c8 +image_tag: #! e.g. latest diff --git a/deploy/README.md b/deploy/README.md index 1eb0eb17..96b0c344 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,5 +1,12 @@ # Deploying +## Connecting Pinniped to an Identity Provider + +If you would like to try Pinniped, but you don't have a compatible identity provider, +you can use Pinniped's test identity provider. +See [deploy-local-user-authenticator/README.md](../deploy-local-user-authenticator/README.md) +for details. + ## Tools This example deployment uses `ytt` from [Carvel](https://carvel.dev/) to template the YAML files. diff --git a/go.mod b/go.mod index 5c6f75b2..246020d6 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/stretchr/testify v1.6.1 github.com/suzerain-io/pinniped/generated/1.19/apis v0.0.0-00010101000000-000000000000 github.com/suzerain-io/pinniped/generated/1.19/client v0.0.0-00010101000000-000000000000 + golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 k8s.io/api v0.19.0 k8s.io/apimachinery v0.19.0 k8s.io/apiserver v0.19.0 diff --git a/go.sum b/go.sum index 790d748e..fb22470c 100644 --- a/go.sum +++ b/go.sum @@ -100,6 +100,7 @@ github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7Do github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/daixiang0/gci v0.0.0-20200727065011-66f1df783cb2 h1:3Lhhps85OdA8ezsEKu+IA1hE+DBTjt/fjd7xNCrHbVA= github.com/daixiang0/gci v0.0.0-20200727065011-66f1df783cb2/go.mod h1:+AV8KmHTGxxwp/pY84TLQfFKp2vuKXXJVzF3kD/hfR4= +github.com/daixiang0/gci v0.2.4 h1:BUCKk5nlK2m+kRIsoj+wb/5hazHvHeZieBKWd9Afa8Q= github.com/daixiang0/gci v0.2.4/go.mod h1:+AV8KmHTGxxwp/pY84TLQfFKp2vuKXXJVzF3kD/hfR4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -134,6 +135,7 @@ github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2H github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-critic/go-critic v0.5.0 h1:Ic2p5UCl5fX/2WX2w8nroPpPhxRNsNTMlJzsu/uqwnM= github.com/go-critic/go-critic v0.5.0/go.mod h1:4jeRh3ZAVnRYhuWdOEvwzVqLUpxMSoAT0xZ74JsTPlo= +github.com/go-critic/go-critic v0.5.2 h1:3RJdgf6u4NZUumoP8nzbqiiNT8e1tC2Oc7jlgqre/IA= github.com/go-critic/go-critic v0.5.2/go.mod h1:cc0+HvdE3lFpqLecgqMaJcvWWH77sLdBp+wLGPM1Yyo= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -197,6 +199,7 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gofrs/flock v0.7.1 h1:DP+LD/t0njgoPBvT5MJLeliUIVQR03hiKR6vezdwHlc= github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/gofrs/flock v0.8.0 h1:MSdYClljsF3PbENUUEx85nkWfJSGfzYI9yEBZOJz6CY= github.com/gofrs/flock v0.8.0/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= @@ -435,6 +438,7 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nishanths/exhaustive v0.0.0-20200708172631-8866003e3856 h1:W3KBC2LFyfgd+wNudlfgCCsTo4q97MeNWrfz8/wSdSc= github.com/nishanths/exhaustive v0.0.0-20200708172631-8866003e3856/go.mod h1:wBEpHwM2OdmeNpdCvRPUlkEbBuaFmcK4Wv8Q7FuGW3c= +github.com/nishanths/exhaustive v0.0.0-20200811152831-6cf413ae40e0 h1:eMV1t2NQRc3r1k3guWiv/zEeqZZP6kPvpUfy6byfL1g= github.com/nishanths/exhaustive v0.0.0-20200811152831-6cf413ae40e0/go.mod h1:wBEpHwM2OdmeNpdCvRPUlkEbBuaFmcK4Wv8Q7FuGW3c= github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -490,6 +494,7 @@ github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40T github.com/quasilyte/go-consistent v0.0.0-20190521200055-c6f3937de18c/go.mod h1:5STLWrekHfjyYwxBRVRXNOSewLJ3PWfDJd1VyTS21fI= github.com/quasilyte/go-ruleguard v0.1.2-0.20200318202121-b00d7a75d3d8 h1:DvnesvLtRPQOvaUbfXfh0tpMHg29by0H7F2U+QIkSu8= github.com/quasilyte/go-ruleguard v0.1.2-0.20200318202121-b00d7a75d3d8/go.mod h1:CGFX09Ci3pq9QZdj86B+VGIdNj4VyCo2iPOGS9esB/k= +github.com/quasilyte/go-ruleguard v0.2.0 h1:UOVMyH2EKkxIfzrULvA9n/tO+HtEhqD9mrLSWMr5FwU= github.com/quasilyte/go-ruleguard v0.2.0/go.mod h1:2RT/tf0Ce0UDj5y243iWKosQogJd8+1G3Rs2fxmlYnw= github.com/quasilyte/regex/syntax v0.0.0-20200407221936-30656e2c4a95 h1:L8QM9bvf68pVdQ3bCFZMDmnt9yqcMBro1pC7F+IPYMY= github.com/quasilyte/regex/syntax v0.0.0-20200407221936-30656e2c4a95/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0= @@ -531,6 +536,7 @@ github.com/sonatard/noctx v0.0.1 h1:VC1Qhl6Oxx9vvWo3UDgrGXYCeKCe3Wbw7qAWL6FrmTY= github.com/sonatard/noctx v0.0.1/go.mod h1:9D2D/EoULe8Yy2joDHJj7bv3sZoq9AaSb8B4lqBjiZI= github.com/sourcegraph/go-diff v0.5.3 h1:lhIKJ2nXLZZ+AfbHpYxTn0pXpNTTui0DX7DO3xeb1Zs= github.com/sourcegraph/go-diff v0.5.3/go.mod h1:v9JDtjCE4HHHCZGId75rg8gkKKa98RVjBcBGsVmMmak= +github.com/sourcegraph/go-diff v0.6.0 h1:WbN9e/jD8ujU+o0vd9IFN5AEwtfB0rn/zM/AANaClqQ= github.com/sourcegraph/go-diff v0.6.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= @@ -551,9 +557,11 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/spf13/viper v1.7.0 h1:xVKxvI7ouOI5I+U9s2eeiUfMaWBVoXA3AWskkrqK0VM= github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= +github.com/spf13/viper v1.7.1 h1:pM5oEahlgWv/WnHXpgbKz7iLIxRf65tye2Ci+XFK5sk= github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= github.com/ssgreg/nlreturn/v2 v2.0.1 h1:+lm6xFjVuNw/9t/Fh5sIwfNWefiD5bddzc6vwJ1TvRI= github.com/ssgreg/nlreturn/v2 v2.0.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= +github.com/ssgreg/nlreturn/v2 v2.1.0 h1:6/s4Rc49L6Uo6RLjhWZGBpWWjfzk2yrf1nIW8m4wgVA= github.com/ssgreg/nlreturn/v2 v2.1.0/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -581,6 +589,7 @@ github.com/tommy-muehle/go-mnd v1.3.1-0.20200224220436-e6f9a994e8fa/go.mod h1:dS github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/ultraware/funlen v0.0.2 h1:Av96YVBwwNSe4MLR7iI/BIa3VyI7/djnto/pK3Uxbdo= github.com/ultraware/funlen v0.0.2/go.mod h1:Dp4UiAus7Wdb9KUZsYWZEWiRzGuM2kXM1lPbfaF6xhA= +github.com/ultraware/funlen v0.0.3 h1:5ylVWm8wsNwH5aWo9438pwvsK0QiqVuUrt9bn7S/iLA= github.com/ultraware/funlen v0.0.3/go.mod h1:Dp4UiAus7Wdb9KUZsYWZEWiRzGuM2kXM1lPbfaF6xhA= github.com/ultraware/whitespace v0.0.4 h1:If7Va4cM03mpgrNH9k49/VOicWpGoG70XPBFFODYDsg= github.com/ultraware/whitespace v0.0.4/go.mod h1:aVMh/gQve5Maj9hQ/hg+F75lr/X5A89uZnzAmWSineA= @@ -784,11 +793,13 @@ golang.org/x/tools v0.0.0-20200626171337-aa94e735be7f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20200701041122-1837592efa10/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305 h1:yaM5S0KcY0lIoZo7Fl+oi91b/DdlU2zuWpfHrpWbCS0= golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200812195022-5ae4c3c160a0 h1:SQvH+DjrwqD1hyyQU+K7JegHz1KEZgEwt17p9d6R2eg= golang.org/x/tools v0.0.0-20200812195022-5ae4c3c160a0/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= @@ -872,6 +883,7 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.4 h1:UoveltGrhghAA7ePc+e+QYDHXrBps2PqFZiHkGR/xK8= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.5 h1:nI5egYTGJakVyOryqLs1cQO5dO0ksin5XXs2pspk75k= honnef.co/go/tools v0.0.1-2020.1.5/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= k8s.io/api v0.19.0 h1:XyrFIJqTYZJ2DU7FBE/bSPz7b1HvbVBuBf07oeo6eTc= k8s.io/api v0.19.0/go.mod h1:I1K45XlvTrDjmj5LoM5LuP/KYrhWbjUKT/SoPG0qTjw= diff --git a/hack/prepare-for-integration-tests.sh b/hack/prepare-for-integration-tests.sh new file mode 100755 index 00000000..df63773d --- /dev/null +++ b/hack/prepare-for-integration-tests.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env bash + +# This script can be used to prepare a kind cluster and deploy the app. +# You can call this script again to redeploy the app. +# It will also output instructions on how to run the integration. + +set -euo pipefail + +# +# Helper functions +# +function log_note() { + GREEN='\033[0;32m' + NC='\033[0m' + if [[ $COLORTERM =~ ^(truecolor|24bit)$ ]]; then + echo -e "${GREEN}$*${NC}" + else + echo "$*" + fi +} + +function log_warning() { + YELLOW='\033[0;33m' + NC='\033[0m' + if [[ $COLORTERM =~ ^(truecolor|24bit)$ ]]; then + echo -e "😒${YELLOW} Warning: $* ${NC}" + else + echo ":/ Warning: $*" + fi +} + +function log_error() { + RED='\033[0;31m' + NC='\033[0m' + if [[ $COLORTERM =~ ^(truecolor|24bit)$ ]]; then + echo -e "🙁${RED} Error: $* ${NC}" + else + echo ":( Error: $*" + fi +} + +# +# Handle argument parsing and help message +# +help=no +skip_build=no + +PARAMS="" +while (("$#")); do + case "$1" in + -h | --help) + help=yes + shift + ;; + -s | --skip-build) + skip_build=yes + shift + ;; + -*) + log_error "Unsupported flag $1" >&2 + exit 1 + ;; + *) + PARAMS="$PARAMS $1" + shift + ;; + esac +done +eval set -- "$PARAMS" + +if [[ "$help" == "yes" ]]; then + me="$(basename "${BASH_SOURCE[0]}")" + echo "Usage:" + echo " $me [flags] [path/to/pinniped]" + echo + echo " path/to/pinniped default: \$PWD ($PWD)" + echo + echo "Flags:" + echo " -h, --help: print this usage" + echo " -s, --skip-build: reuse the most recently built image of the app instead of building" + exit 1 +fi + +pinniped_path="${1-$PWD}" + +# +# Check for dependencies +# +if ! command -v kind >/dev/null; then + log_error "Please install kind. e.g. 'brew install kind' for MacOS" + exit 1 +fi + +if ! command -v ytt >/dev/null; then + log_error "Please install ytt. e.g. 'brew tap k14s/tap && brew install ytt' for MacOS" + exit 1 +fi + +if ! command -v kapp >/dev/null; then + log_error "Please install kapp. e.g. 'brew tap k14s/tap && brew install kapp' for MacOS" + exit 1 +fi + +if ! command -v kubectl >/dev/null; then + log_error "Please install kubectl. e.g. 'brew install kubectl' for MacOS" + exit 1 +fi + +cd "$pinniped_path" || exit 1 + +if [[ ! -f Dockerfile || ! -d deploy ]]; then + log_error "$pinniped_path does not appear to be the path to the source code repo directory" + exit 1 +fi + +# +# Setup kind and build the app +# +log_note "Checking for running kind clusters..." +if ! kind get clusters | grep -q -e '^kind$'; then + log_note "Creating a kind cluster..." + kind create cluster +else + if ! kubectl cluster-info | grep master | grep -q 127.0.0.1; then + log_error "Seems like your kubeconfig is not targeting a local cluster." + log_error "Exiting to avoid accidentally running tests against a real cluster." + exit 1 + fi +fi + +registry="docker.io" +repo="test/build" +registry_repo="$registry/$repo" +tag=$(uuidgen) # always a new tag to force K8s to reload the image on redeploy + +if [[ "$skip_build" == "yes" ]]; then + most_recent_tag=$(docker images "$repo" --format "{{.Tag}}" | head -1) + if [[ -n "$most_recent_tag" ]]; then + tag="$most_recent_tag" + do_build=no + else + # Oops, there was no previous build. Need to build anyway. + do_build=yes + fi +else + do_build=yes +fi + +registry_repo_tag="${registry_repo}:${tag}" + +if [[ "$do_build" == "yes" ]]; then + # Rebuild the code + log_note "Docker building the app..." + docker build . --tag "$registry_repo_tag" +fi + +# Load it into the cluster +log_note "Loading the app's container image into the kind cluster..." +kind load docker-image "$registry_repo_tag" + +manifest=/tmp/manifest.yaml + +# +# Deploy local-user-authenticator +# +pushd deploy-local-user-authenticator >/dev/null + +log_note "Deploying the local-user-authenticator app to the cluster..." +ytt --file . \ + --data-value "image_repo=$registry_repo" \ + --data-value "image_tag=$tag" >"$manifest" + +kubectl apply --dry-run=client -f "$manifest" # Validate manifest schema. +kapp deploy --yes --app local-user-authenticator --diff-changes --file "$manifest" + +popd >/dev/null + +test_username="test-username" +test_groups="test-group-0,test-group-1" +set +o pipefail +test_password="$(cat /dev/urandom | env LC_CTYPE=C tr -dc 'a-z0-9' | fold -w 32 | head -n 1)" +set -o pipefail +if [[ ${#test_password} -ne 32 ]]; then + log_error "Could not create random test user password" + exit 1 +fi +log_note "Creating test user '$test_username'..." +kubectl create secret generic "$test_username" \ + --namespace local-user-authenticator \ + --from-literal=groups="$test_groups" \ + --from-literal=passwordHash="$(htpasswd -nbBC 10 x "$test_password" | sed -e "s/^x://")" \ + --dry-run=client \ + --output yaml | + kubectl apply -f - + +app_name="pinniped" +namespace="integration" +webhook_url="https://local-user-authenticator.local-user-authenticator.svc/authenticate" +webhook_ca_bundle="$(kubectl get secret api-serving-cert --namespace local-user-authenticator -o 'jsonpath={.data.caCertificate}')" +discovery_url="$(TERM=dumb kubectl cluster-info | awk '/Kubernetes master/ {print $NF}')" + +# +# Deploy Pinniped +# +pushd deploy >/dev/null + +log_note "Deploying the Pinniped app to the cluster..." +ytt --file . \ + --data-value "app_name=$app_name" \ + --data-value "namespace=$namespace" \ + --data-value "image_repo=$registry_repo" \ + --data-value "image_tag=$tag" \ + --data-value "webhook_url=$webhook_url" \ + --data-value "webhook_ca_bundle=$webhook_ca_bundle" \ + --data-value "discovery_url=$discovery_url" >"$manifest" + +kubectl apply --dry-run=client -f "$manifest" # Validate manifest schema. +kapp deploy --yes --app "$app_name" --diff-changes --file "$manifest" + +popd >/dev/null + +# +# Create the environment file +# +kind_capabilities_file="$pinniped_path/test/cluster_capabilities/kind.yaml" +pinniped_cluster_capability_file_content=$(cat "$kind_capabilities_file") + +cat </tmp/integration-test-env +# The following env vars should be set before running 'cd test && go test ./...' +export PINNIPED_NAMESPACE=${namespace} +export PINNIPED_APP_NAME=${app_name} +export PINNIPED_TEST_USER_USERNAME=${test_username} +export PINNIPED_TEST_USER_GROUPS=${test_groups} +export PINNIPED_TEST_USER_TOKEN=${test_username}:${test_password} + +read -r -d '' PINNIPED_CLUSTER_CAPABILITY_YAML << PINNIPED_CLUSTER_CAPABILITY_YAML_EOF || true +${pinniped_cluster_capability_file_content} +PINNIPED_CLUSTER_CAPABILITY_YAML_EOF + +export PINNIPED_CLUSTER_CAPABILITY_YAML +EOF + +# +# Print instructions for next steps +# +goland_vars=$(grep -v '^#' /tmp/integration-test-env | grep -E '^export .+=' | sed 's/export //g' | tr '\n' ';') + +log_note "Done!" +log_note +log_note "Ready to run integration tests. For example, you could run all tests using the following commands..." +log_note " cd $pinniped_path" +log_note ' source /tmp/integration-test-env' +log_note ' (cd test && go test -count 1 ./...)' +log_note +log_note '"Environment" setting for GoLand run configurations:' +log_note " ${goland_vars}PINNIPED_CLUSTER_CAPABILITY_FILE=${kind_capabilities_file}" +log_note +log_note +log_note "You can run this script again to deploy local production code changes while you are working." +log_note +log_note "When you're finished, use 'kind delete cluster' to tear down the cluster." +log_note +log_note "To delete the deployments, run 'kapp delete -a local-user-authenticator -y && kapp delete -a pinniped -y'." diff --git a/internal/controller/apicerts/apiservice_updater.go b/internal/controller/apicerts/apiservice_updater.go new file mode 100644 index 00000000..af31f238 --- /dev/null +++ b/internal/controller/apicerts/apiservice_updater.go @@ -0,0 +1,69 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package apicerts + +import ( + "fmt" + + k8serrors "k8s.io/apimachinery/pkg/api/errors" + corev1informers "k8s.io/client-go/informers/core/v1" + "k8s.io/klog/v2" + aggregatorclient "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset" + + pinnipedcontroller "github.com/suzerain-io/pinniped/internal/controller" + "github.com/suzerain-io/pinniped/internal/controllerlib" +) + +type apiServiceUpdaterController struct { + namespace string + aggregatorClient aggregatorclient.Interface + secretInformer corev1informers.SecretInformer +} + +func NewAPIServiceUpdaterController( + namespace string, + aggregatorClient aggregatorclient.Interface, + secretInformer corev1informers.SecretInformer, + withInformer pinnipedcontroller.WithInformerOptionFunc, +) controllerlib.Controller { + return controllerlib.New( + controllerlib.Config{ + Name: "certs-manager-controller", + Syncer: &apiServiceUpdaterController{ + namespace: namespace, + aggregatorClient: aggregatorClient, + secretInformer: secretInformer, + }, + }, + withInformer( + secretInformer, + pinnipedcontroller.NameAndNamespaceExactMatchFilterFactory(certsSecretName, namespace), + controllerlib.InformerOption{}, + ), + ) +} + +func (c *apiServiceUpdaterController) Sync(ctx controllerlib.Context) error { + // Try to get the secret from the informer cache. + certSecret, err := c.secretInformer.Lister().Secrets(c.namespace).Get(certsSecretName) + notFound := k8serrors.IsNotFound(err) + if err != nil && !notFound { + return fmt.Errorf("failed to get %s/%s secret: %w", c.namespace, certsSecretName, err) + } + if notFound { + // The secret does not exist yet, so nothing to do. + klog.Info("apiServiceUpdaterController Sync found that the secret does not exist yet or was deleted") + return nil + } + + // Update the APIService to give it the new CA bundle. + if err := UpdateAPIService(ctx.Context, c.aggregatorClient, certSecret.Data[caCertificateSecretKey]); err != nil { + return fmt.Errorf("could not update the API service: %w", err) + } + + klog.Info("apiServiceUpdaterController Sync successfully updated API service") + return nil +} diff --git a/internal/controller/apicerts/apiservice_updater_test.go b/internal/controller/apicerts/apiservice_updater_test.go new file mode 100644 index 00000000..1a1bf55e --- /dev/null +++ b/internal/controller/apicerts/apiservice_updater_test.go @@ -0,0 +1,277 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package apicerts + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + kubeinformers "k8s.io/client-go/informers" + kubernetesfake "k8s.io/client-go/kubernetes/fake" + coretesting "k8s.io/client-go/testing" + apiregistrationv1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1" + aggregatorfake "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/fake" + + pinnipedv1alpha1 "github.com/suzerain-io/pinniped/generated/1.19/apis/pinniped/v1alpha1" + "github.com/suzerain-io/pinniped/internal/controllerlib" + "github.com/suzerain-io/pinniped/internal/testutil" +) + +func TestAPIServiceUpdaterControllerOptions(t *testing.T) { + spec.Run(t, "options", func(t *testing.T, when spec.G, it spec.S) { + const installedInNamespace = "some-namespace" + + var r *require.Assertions + var observableWithInformerOption *testutil.ObservableWithInformerOption + var secretsInformerFilter controllerlib.Filter + + it.Before(func() { + r = require.New(t) + observableWithInformerOption = testutil.NewObservableWithInformerOption() + secretsInformer := kubeinformers.NewSharedInformerFactory(nil, 0).Core().V1().Secrets() + _ = NewAPIServiceUpdaterController( + installedInNamespace, + nil, + secretsInformer, + observableWithInformerOption.WithInformer, // make it possible to observe the behavior of the Filters + ) + secretsInformerFilter = observableWithInformerOption.GetFilterForInformer(secretsInformer) + }) + + when("watching Secret objects", func() { + var subject controllerlib.Filter + var target, wrongNamespace, wrongName, unrelated *corev1.Secret + + it.Before(func() { + subject = secretsInformerFilter + target = &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "api-serving-cert", Namespace: installedInNamespace}} + wrongNamespace = &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "api-serving-cert", Namespace: "wrong-namespace"}} + wrongName = &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "wrong-name", Namespace: installedInNamespace}} + unrelated = &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "wrong-name", Namespace: "wrong-namespace"}} + }) + + when("the target Secret changes", func() { + it("returns true to trigger the sync method", func() { + r.True(subject.Add(target)) + r.True(subject.Update(target, unrelated)) + r.True(subject.Update(unrelated, target)) + r.True(subject.Delete(target)) + }) + }) + + when("a Secret from another namespace changes", func() { + it("returns false to avoid triggering the sync method", func() { + r.False(subject.Add(wrongNamespace)) + r.False(subject.Update(wrongNamespace, unrelated)) + r.False(subject.Update(unrelated, wrongNamespace)) + r.False(subject.Delete(wrongNamespace)) + }) + }) + + when("a Secret with a different name changes", func() { + it("returns false to avoid triggering the sync method", func() { + r.False(subject.Add(wrongName)) + r.False(subject.Update(wrongName, unrelated)) + r.False(subject.Update(unrelated, wrongName)) + r.False(subject.Delete(wrongName)) + }) + }) + + when("a Secret with a different name and a different namespace changes", func() { + it("returns false to avoid triggering the sync method", func() { + r.False(subject.Add(unrelated)) + r.False(subject.Update(unrelated, unrelated)) + r.False(subject.Delete(unrelated)) + }) + }) + }) + }, spec.Parallel(), spec.Report(report.Terminal{})) +} + +func TestAPIServiceUpdaterControllerSync(t *testing.T) { + spec.Run(t, "Sync", func(t *testing.T, when spec.G, it spec.S) { + const installedInNamespace = "some-namespace" + + var r *require.Assertions + + var subject controllerlib.Controller + var aggregatorAPIClient *aggregatorfake.Clientset + var kubeInformerClient *kubernetesfake.Clientset + var kubeInformers kubeinformers.SharedInformerFactory + var timeoutContext context.Context + var timeoutContextCancel context.CancelFunc + var syncContext *controllerlib.Context + + // Defer starting the informers until the last possible moment so that the + // nested Before's can keep adding things to the informer caches. + var startInformersAndController = func() { + // Set this at the last second to allow for injection of server override. + subject = NewAPIServiceUpdaterController( + installedInNamespace, + aggregatorAPIClient, + kubeInformers.Core().V1().Secrets(), + controllerlib.WithInformer, + ) + + // Set this at the last second to support calling subject.Name(). + syncContext = &controllerlib.Context{ + Context: timeoutContext, + Name: subject.Name(), + Key: controllerlib.Key{ + Namespace: installedInNamespace, + Name: "api-serving-cert", + }, + } + + // Must start informers before calling TestRunSynchronously() + kubeInformers.Start(timeoutContext.Done()) + controllerlib.TestRunSynchronously(t, subject) + } + + it.Before(func() { + r = require.New(t) + + timeoutContext, timeoutContextCancel = context.WithTimeout(context.Background(), time.Second*3) + + kubeInformerClient = kubernetesfake.NewSimpleClientset() + kubeInformers = kubeinformers.NewSharedInformerFactory(kubeInformerClient, 0) + aggregatorAPIClient = aggregatorfake.NewSimpleClientset() + }) + + it.After(func() { + timeoutContextCancel() + }) + + when("there is not yet an api-serving-cert Secret in the installation namespace or it was deleted", func() { + it.Before(func() { + unrelatedSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some other secret", + Namespace: installedInNamespace, + }, + } + err := kubeInformerClient.Tracker().Add(unrelatedSecret) + r.NoError(err) + }) + + it("does not need to make any API calls with its API client", func() { + startInformersAndController() + err := controllerlib.TestSync(t, subject, *syncContext) + r.NoError(err) + r.Empty(aggregatorAPIClient.Actions()) + }) + }) + + when("there is an api-serving-cert Secret already in the installation namespace", func() { + it.Before(func() { + apiServingCertSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "api-serving-cert", + Namespace: installedInNamespace, + }, + Data: map[string][]byte{ + "caCertificate": []byte("fake CA cert"), + "tlsPrivateKey": []byte("fake private key"), + "tlsCertificateChain": []byte("fake cert chain"), + }, + } + err := kubeInformerClient.Tracker().Add(apiServingCertSecret) + r.NoError(err) + }) + + when("the APIService exists", func() { + it.Before(func() { + apiService := &apiregistrationv1.APIService{ + ObjectMeta: metav1.ObjectMeta{ + Name: pinnipedv1alpha1.SchemeGroupVersion.Version + "." + pinnipedv1alpha1.GroupName, + }, + Spec: apiregistrationv1.APIServiceSpec{ + CABundle: nil, + VersionPriority: 1234, + }, + } + err := aggregatorAPIClient.Tracker().Add(apiService) + r.NoError(err) + }) + + it("updates the APIService's ca bundle", func() { + startInformersAndController() + err := controllerlib.TestSync(t, subject, *syncContext) + r.NoError(err) + + // Make sure we updated the APIService caBundle and left it otherwise unchanged + r.Len(aggregatorAPIClient.Actions(), 2) + r.Equal("get", aggregatorAPIClient.Actions()[0].GetVerb()) + expectedAPIServiceName := pinnipedv1alpha1.SchemeGroupVersion.Version + "." + pinnipedv1alpha1.GroupName + expectedUpdateAction := coretesting.NewUpdateAction( + schema.GroupVersionResource{ + Group: apiregistrationv1.GroupName, + Version: "v1", + Resource: "apiservices", + }, + "", + &apiregistrationv1.APIService{ + ObjectMeta: metav1.ObjectMeta{ + Name: expectedAPIServiceName, + Namespace: "", + }, + Spec: apiregistrationv1.APIServiceSpec{ + VersionPriority: 1234, // only the CABundle is updated, this other field is left unchanged + CABundle: []byte("fake CA cert"), + }, + }, + ) + r.Equal(expectedUpdateAction, aggregatorAPIClient.Actions()[1]) + }) + + when("updating the APIService fails", func() { + it.Before(func() { + aggregatorAPIClient.PrependReactor( + "update", + "apiservices", + func(_ coretesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("update failed") + }, + ) + }) + + it("returns the update error", func() { + startInformersAndController() + err := controllerlib.TestSync(t, subject, *syncContext) + r.EqualError(err, "could not update the API service: could not update API service: update failed") + }) + }) + }) + + when("the APIService does not exist", func() { + it.Before(func() { + unrelatedAPIService := &apiregistrationv1.APIService{ + ObjectMeta: metav1.ObjectMeta{Name: "some other api service"}, + Spec: apiregistrationv1.APIServiceSpec{}, + } + err := aggregatorAPIClient.Tracker().Add(unrelatedAPIService) + r.NoError(err) + }) + + it("returns an error", func() { + startInformersAndController() + err := controllerlib.TestSync(t, subject, *syncContext) + r.Error(err) + r.Regexp("could not get existing version of API service: .* not found", err.Error()) + }) + }) + }) + }, spec.Parallel(), spec.Report(report.Terminal{})) +} diff --git a/internal/controller/apicerts/certs_expirer.go b/internal/controller/apicerts/certs_expirer.go index 52e85aba..9cfd3ab3 100644 --- a/internal/controller/apicerts/certs_expirer.go +++ b/internal/controller/apicerts/certs_expirer.go @@ -69,7 +69,7 @@ func (c *certsExpirerController) Sync(ctx controllerlib.Context) error { return fmt.Errorf("failed to get %s/%s secret: %w", c.namespace, certsSecretName, err) } if notFound { - klog.Info("certsExpirerController Sync() found that the secret does not exist yet or was deleted") + klog.Info("certsExpirerController Sync found that the secret does not exist yet or was deleted") return nil } @@ -78,13 +78,13 @@ func (c *certsExpirerController) Sync(ctx controllerlib.Context) error { // If we can't read the cert, then really all we can do is log something, // since if we returned an error then the controller lib would just call us // again and again, which would probably yield the same results. - klog.Warningf("certsExpirerController Sync() found that the secret is malformed: %s", err.Error()) + klog.Warningf("certsExpirerController Sync found that the secret is malformed: %s", err.Error()) return nil } certAge := time.Since(notBefore) renewDelta := certAge - c.renewBefore - klog.Infof("certsExpirerController Sync() found a renew delta of %s", renewDelta) + klog.Infof("certsExpirerController Sync found a renew delta of %s", renewDelta) if renewDelta >= 0 || time.Now().After(notAfter) { err := c.k8sClient. CoreV1(). diff --git a/internal/controller/apicerts/certs_manager.go b/internal/controller/apicerts/certs_manager.go index db0503ce..4432ba45 100644 --- a/internal/controller/apicerts/certs_manager.go +++ b/internal/controller/apicerts/certs_manager.go @@ -16,7 +16,6 @@ import ( corev1informers "k8s.io/client-go/informers/core/v1" "k8s.io/client-go/kubernetes" "k8s.io/klog/v2" - aggregatorclient "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset" "github.com/suzerain-io/pinniped/internal/certauthority" pinnipedcontroller "github.com/suzerain-io/pinniped/internal/controller" @@ -32,34 +31,37 @@ const ( ) type certsManagerController struct { - namespace string - k8sClient kubernetes.Interface - aggregatorClient aggregatorclient.Interface - secretInformer corev1informers.SecretInformer + namespace string + k8sClient kubernetes.Interface + secretInformer corev1informers.SecretInformer // certDuration is the lifetime of both the serving certificate and its CA // certificate that this controller will use when issuing the certificates. certDuration time.Duration + + generatedCACommonName string + serviceNameForGeneratedCertCommonName string } -func NewCertsManagerController( - namespace string, +func NewCertsManagerController(namespace string, k8sClient kubernetes.Interface, - aggregatorClient aggregatorclient.Interface, secretInformer corev1informers.SecretInformer, withInformer pinnipedcontroller.WithInformerOptionFunc, withInitialEvent pinnipedcontroller.WithInitialEventOptionFunc, certDuration time.Duration, + generatedCACommonName string, + serviceNameForGeneratedCertCommonName string, ) controllerlib.Controller { return controllerlib.New( controllerlib.Config{ Name: "certs-manager-controller", Syncer: &certsManagerController{ - namespace: namespace, - k8sClient: k8sClient, - aggregatorClient: aggregatorClient, - secretInformer: secretInformer, - certDuration: certDuration, + namespace: namespace, + k8sClient: k8sClient, + secretInformer: secretInformer, + certDuration: certDuration, + generatedCACommonName: generatedCACommonName, + serviceNameForGeneratedCertCommonName: serviceNameForGeneratedCertCommonName, }, }, withInformer( @@ -88,16 +90,13 @@ func (c *certsManagerController) Sync(ctx controllerlib.Context) error { } // Create a CA. - aggregatedAPIServerCA, err := certauthority.New(pkix.Name{CommonName: "Pinniped CA"}, c.certDuration) + aggregatedAPIServerCA, err := certauthority.New(pkix.Name{CommonName: c.generatedCACommonName}, c.certDuration) if err != nil { return fmt.Errorf("could not initialize CA: %w", err) } - // This string must match the name of the Service declared in the deployment yaml. - const serviceName = "pinniped-api" - // Using the CA from above, create a TLS server cert for the aggregated API server to use. - serviceEndpoint := serviceName + "." + c.namespace + ".svc" + serviceEndpoint := c.serviceNameForGeneratedCertCommonName + "." + c.namespace + ".svc" aggregatedAPIServerTLSCert, err := aggregatedAPIServerCA.Issue( pkix.Name{CommonName: serviceEndpoint}, []string{serviceEndpoint}, @@ -129,11 +128,6 @@ func (c *certsManagerController) Sync(ctx controllerlib.Context) error { return fmt.Errorf("could not create secret: %w", err) } - // Update the APIService to give it the new CA bundle. - if err := UpdateAPIService(ctx.Context, c.aggregatorClient, aggregatedAPIServerCA.Bundle()); err != nil { - return fmt.Errorf("could not update the API service: %w", err) - } - - klog.Info("certsManagerController Sync successfully created secret and updated API service") + klog.Info("certsManagerController Sync successfully created secret") return nil } diff --git a/internal/controller/apicerts/certs_manager_test.go b/internal/controller/apicerts/certs_manager_test.go index 0f1d1f5f..fd27c806 100644 --- a/internal/controller/apicerts/certs_manager_test.go +++ b/internal/controller/apicerts/certs_manager_test.go @@ -21,10 +21,7 @@ import ( kubeinformers "k8s.io/client-go/informers" kubernetesfake "k8s.io/client-go/kubernetes/fake" coretesting "k8s.io/client-go/testing" - apiregistrationv1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1" - aggregatorfake "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/fake" - pinnipedv1alpha1 "github.com/suzerain-io/pinniped/generated/1.19/apis/pinniped/v1alpha1" "github.com/suzerain-io/pinniped/internal/controllerlib" "github.com/suzerain-io/pinniped/internal/testutil" ) @@ -43,15 +40,7 @@ func TestManagerControllerOptions(t *testing.T) { observableWithInformerOption = testutil.NewObservableWithInformerOption() observableWithInitialEventOption = testutil.NewObservableWithInitialEventOption() secretsInformer := kubeinformers.NewSharedInformerFactory(nil, 0).Core().V1().Secrets() - _ = NewCertsManagerController( - installedInNamespace, - nil, - nil, - secretsInformer, - observableWithInformerOption.WithInformer, // make it possible to observe the behavior of the Filters - observableWithInitialEventOption.WithInitialEvent, // make it possible to observe the behavior of the initial event - 0, // certDuration, not needed for this test - ) + _ = NewCertsManagerController(installedInNamespace, nil, secretsInformer, observableWithInformerOption.WithInformer, observableWithInitialEventOption.WithInitialEvent, 0, "Pinniped CA", "pinniped-api") secretsInformerFilter = observableWithInformerOption.GetFilterForInformer(secretsInformer) }) @@ -123,7 +112,6 @@ func TestManagerControllerSync(t *testing.T) { var subject controllerlib.Controller var kubeAPIClient *kubernetesfake.Clientset - var aggregatorAPIClient *aggregatorfake.Clientset var kubeInformerClient *kubernetesfake.Clientset var kubeInformers kubeinformers.SharedInformerFactory var timeoutContext context.Context @@ -137,11 +125,12 @@ func TestManagerControllerSync(t *testing.T) { subject = NewCertsManagerController( installedInNamespace, kubeAPIClient, - aggregatorAPIClient, kubeInformers.Core().V1().Secrets(), controllerlib.WithInformer, controllerlib.WithInitialEvent, certDuration, + "Pinniped CA", + "pinniped-api", ) // Set this at the last second to support calling subject.Name(). @@ -167,7 +156,6 @@ func TestManagerControllerSync(t *testing.T) { kubeInformerClient = kubernetesfake.NewSimpleClientset() kubeInformers = kubeinformers.NewSharedInformerFactory(kubeInformerClient, 0) kubeAPIClient = kubernetesfake.NewSimpleClientset() - aggregatorAPIClient = aggregatorfake.NewSimpleClientset() }) it.After(func() { @@ -186,111 +174,35 @@ func TestManagerControllerSync(t *testing.T) { r.NoError(err) }) - when("the APIService exists", func() { - it.Before(func() { - apiService := &apiregistrationv1.APIService{ - ObjectMeta: metav1.ObjectMeta{ - Name: pinnipedv1alpha1.SchemeGroupVersion.Version + "." + pinnipedv1alpha1.GroupName, - }, - Spec: apiregistrationv1.APIServiceSpec{ - CABundle: nil, - VersionPriority: 1234, - }, - } - err := aggregatorAPIClient.Tracker().Add(apiService) - r.NoError(err) - }) + it("creates the api-serving-cert Secret", func() { + startInformersAndController() + err := controllerlib.TestSync(t, subject, *syncContext) + r.NoError(err) - it("creates the api-serving-cert Secret and updates the APIService's ca bundle", func() { - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.NoError(err) + // Check all the relevant fields from the create Secret action + r.Len(kubeAPIClient.Actions(), 1) + actualAction := kubeAPIClient.Actions()[0].(coretesting.CreateActionImpl) + r.Equal(schema.GroupVersionResource{Group: "", Version: "v1", Resource: "secrets"}, actualAction.GetResource()) + r.Equal(installedInNamespace, actualAction.GetNamespace()) + actualSecret := actualAction.GetObject().(*corev1.Secret) + r.Equal("api-serving-cert", actualSecret.Name) + r.Equal(installedInNamespace, actualSecret.Namespace) + actualCACert := actualSecret.StringData["caCertificate"] + actualPrivateKey := actualSecret.StringData["tlsPrivateKey"] + actualCertChain := actualSecret.StringData["tlsCertificateChain"] + r.NotEmpty(actualCACert) + r.NotEmpty(actualPrivateKey) + r.NotEmpty(actualCertChain) - // Check all the relevant fields from the create Secret action - r.Len(kubeAPIClient.Actions(), 1) - actualAction := kubeAPIClient.Actions()[0].(coretesting.CreateActionImpl) - r.Equal(schema.GroupVersionResource{Group: "", Version: "v1", Resource: "secrets"}, actualAction.GetResource()) - r.Equal(installedInNamespace, actualAction.GetNamespace()) - actualSecret := actualAction.GetObject().(*corev1.Secret) - r.Equal("api-serving-cert", actualSecret.Name) - r.Equal(installedInNamespace, actualSecret.Namespace) - actualCACert := actualSecret.StringData["caCertificate"] - actualPrivateKey := actualSecret.StringData["tlsPrivateKey"] - actualCertChain := actualSecret.StringData["tlsCertificateChain"] - r.NotEmpty(actualCACert) - r.NotEmpty(actualPrivateKey) - r.NotEmpty(actualCertChain) + // Validate the created CA's lifetime. + validCACert := testutil.ValidateCertificate(t, actualCACert, actualCACert) + validCACert.RequireLifetime(time.Now(), time.Now().Add(certDuration), 6*time.Minute) - // Validate the created CA's lifetime. - validCACert := testutil.ValidateCertificate(t, actualCACert, actualCACert) - validCACert.RequireLifetime(time.Now(), time.Now().Add(certDuration), 6*time.Minute) - - // Validate the created cert using the CA, and also validate the cert's hostname - validCert := testutil.ValidateCertificate(t, actualCACert, actualCertChain) - validCert.RequireDNSName("pinniped-api." + installedInNamespace + ".svc") - validCert.RequireLifetime(time.Now(), time.Now().Add(certDuration), 6*time.Minute) - validCert.RequireMatchesPrivateKey(actualPrivateKey) - - // Make sure we updated the APIService caBundle and left it otherwise unchanged - r.Len(aggregatorAPIClient.Actions(), 2) - r.Equal("get", aggregatorAPIClient.Actions()[0].GetVerb()) - expectedAPIServiceName := pinnipedv1alpha1.SchemeGroupVersion.Version + "." + pinnipedv1alpha1.GroupName - expectedUpdateAction := coretesting.NewUpdateAction( - schema.GroupVersionResource{ - Group: apiregistrationv1.GroupName, - Version: "v1", - Resource: "apiservices", - }, - "", - &apiregistrationv1.APIService{ - ObjectMeta: metav1.ObjectMeta{ - Name: expectedAPIServiceName, - Namespace: "", - }, - Spec: apiregistrationv1.APIServiceSpec{ - VersionPriority: 1234, // only the CABundle is updated, this other field is left unchanged - CABundle: []byte(actualCACert), - }, - }, - ) - r.Equal(expectedUpdateAction, aggregatorAPIClient.Actions()[1]) - }) - - when("updating the APIService fails", func() { - it.Before(func() { - aggregatorAPIClient.PrependReactor( - "update", - "apiservices", - func(_ coretesting.Action) (bool, runtime.Object, error) { - return true, nil, errors.New("update failed") - }, - ) - }) - - it("returns the update error", func() { - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.EqualError(err, "could not update the API service: could not update API service: update failed") - }) - }) - }) - - when("the APIService does not exist", func() { - it.Before(func() { - unrelatedAPIService := &apiregistrationv1.APIService{ - ObjectMeta: metav1.ObjectMeta{Name: "some other api service"}, - Spec: apiregistrationv1.APIServiceSpec{}, - } - err := aggregatorAPIClient.Tracker().Add(unrelatedAPIService) - r.NoError(err) - }) - - it("returns an error", func() { - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.Error(err) - r.Regexp("could not get existing version of API service: .* not found", err.Error()) - }) + // Validate the created cert using the CA, and also validate the cert's hostname + validCert := testutil.ValidateCertificate(t, actualCACert, actualCertChain) + validCert.RequireDNSName("pinniped-api." + installedInNamespace + ".svc") + validCert.RequireLifetime(time.Now(), time.Now().Add(certDuration), 6*time.Minute) + validCert.RequireMatchesPrivateKey(actualPrivateKey) }) when("creating the Secret fails", func() { @@ -304,11 +216,10 @@ func TestManagerControllerSync(t *testing.T) { ) }) - it("returns the create error and does not update the APIService", func() { + it("returns the create error", func() { startInformersAndController() err := controllerlib.TestSync(t, subject, *syncContext) r.EqualError(err, "could not create secret: create failed") - r.Empty(aggregatorAPIClient.Actions()) }) }) }) @@ -325,12 +236,11 @@ func TestManagerControllerSync(t *testing.T) { r.NoError(err) }) - it("does not need to make any API calls with its API clients", func() { + it("does not need to make any API calls with its API client", func() { startInformersAndController() err := controllerlib.TestSync(t, subject, *syncContext) r.NoError(err) r.Empty(kubeAPIClient.Actions()) - r.Empty(aggregatorAPIClient.Actions()) }) }) }, spec.Parallel(), spec.Report(report.Terminal{})) diff --git a/internal/controller/apicerts/certs_observer.go b/internal/controller/apicerts/certs_observer.go index a1a5108e..5837f0e5 100644 --- a/internal/controller/apicerts/certs_observer.go +++ b/internal/controller/apicerts/certs_observer.go @@ -54,7 +54,7 @@ func (c *certsObserverController) Sync(_ controllerlib.Context) error { return fmt.Errorf("failed to get %s/%s secret: %w", c.namespace, certsSecretName, err) } if notFound { - klog.Info("certsObserverController Sync() found that the secret does not exist yet or was deleted") + klog.Info("certsObserverController Sync found that the secret does not exist yet or was deleted") // The secret does not exist yet or was deleted. c.dynamicCertProvider.Set(nil, nil) return nil diff --git a/internal/controller/apicerts/update_api_service.go b/internal/controller/apicerts/update_api_service.go index faf6eb55..db39c5c3 100644 --- a/internal/controller/apicerts/update_api_service.go +++ b/internal/controller/apicerts/update_api_service.go @@ -6,6 +6,7 @@ SPDX-License-Identifier: Apache-2.0 package apicerts import ( + "bytes" "context" "fmt" @@ -28,6 +29,11 @@ func UpdateAPIService(ctx context.Context, aggregatorClient aggregatorclient.Int return fmt.Errorf("could not get existing version of API service: %w", err) } + if bytes.Equal(fetchedAPIService.Spec.CABundle, aggregatedAPIServerCA) { + // Already has the same value, perhaps because another process already updated the object, so no need to update. + return nil + } + // Update just the field we care about. fetchedAPIService.Spec.CABundle = aggregatedAPIServerCA diff --git a/internal/controller/apicerts/update_api_service_test.go b/internal/controller/apicerts/update_api_service_test.go index a615a6a8..b9fff892 100644 --- a/internal/controller/apicerts/update_api_service_test.go +++ b/internal/controller/apicerts/update_api_service_test.go @@ -70,17 +70,44 @@ func TestUpdateAPIService(t *testing.T) { }, }}, }, + { + name: "happy path update when the pre-existing APIService already has the same CA bundle so there is no need to update", + mocks: func(c *aggregatorv1fake.Clientset) { + _ = c.Tracker().Add(&apiregistrationv1.APIService{ + ObjectMeta: metav1.ObjectMeta{Name: apiServiceName}, + Spec: apiregistrationv1.APIServiceSpec{ + GroupPriorityMinimum: 999, + CABundle: []byte("some-ca-bundle"), + }, + }) + c.PrependReactor("update", "apiservices", func(_ kubetesting.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("should not encounter this error because update should be skipped in this case") + }) + }, + caInput: []byte("some-ca-bundle"), + wantObjects: []apiregistrationv1.APIService{{ + ObjectMeta: metav1.ObjectMeta{Name: apiServiceName}, + Spec: apiregistrationv1.APIServiceSpec{ + GroupPriorityMinimum: 999, + CABundle: []byte("some-ca-bundle"), // unchanged + }, + }}, + }, { name: "error on update", mocks: func(c *aggregatorv1fake.Clientset) { _ = c.Tracker().Add(&apiregistrationv1.APIService{ ObjectMeta: metav1.ObjectMeta{Name: apiServiceName}, - Spec: apiregistrationv1.APIServiceSpec{}, + Spec: apiregistrationv1.APIServiceSpec{ + GroupPriorityMinimum: 999, + CABundle: []byte("some-other-different-ca-bundle"), + }, }) c.PrependReactor("update", "apiservices", func(_ kubetesting.Action) (bool, runtime.Object, error) { return true, nil, fmt.Errorf("error on update") }) }, + caInput: []byte("some-ca-bundle"), wantErr: "could not update API service: error on update", }, { @@ -143,6 +170,7 @@ func TestUpdateAPIService(t *testing.T) { }}, }, } + for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { diff --git a/internal/controllermanager/prepare_controllers.go b/internal/controllermanager/prepare_controllers.go index 191cf8f0..dcb565fa 100644 --- a/internal/controllermanager/prepare_controllers.go +++ b/internal/controllermanager/prepare_controllers.go @@ -47,6 +47,9 @@ func PrepareControllers( kubePublicNamespaceK8sInformers, installationNamespaceK8sInformers, installationNamespacePinnipedInformers := createInformers(serverInstallationNamespace, k8sClient, pinnipedClient) + // This string must match the name of the Service declared in the deployment yaml. + const serviceName = "pinniped-api" + // Create controller manager. controllerManager := controllerlib. NewManager(). @@ -65,11 +68,21 @@ func PrepareControllers( apicerts.NewCertsManagerController( serverInstallationNamespace, k8sClient, - aggregatorClient, installationNamespaceK8sInformers.Core().V1().Secrets(), controllerlib.WithInformer, controllerlib.WithInitialEvent, servingCertDuration, + "Pinniped CA", + serviceName, + ), + singletonWorker, + ). + WithController( + apicerts.NewAPIServiceUpdaterController( + serverInstallationNamespace, + aggregatorClient, + installationNamespaceK8sInformers.Core().V1().Secrets(), + controllerlib.WithInformer, ), singletonWorker, ). diff --git a/test/integration/client_test.go b/test/integration/client_test.go index 38d476ae..ad480d1a 100644 --- a/test/integration/client_test.go +++ b/test/integration/client_test.go @@ -57,7 +57,7 @@ var maskKey = func(s string) string { return strings.ReplaceAll(s, "TESTING KEY" func TestClient(t *testing.T) { library.SkipUnlessIntegration(t) library.SkipUnlessClusterHasCapability(t, library.ClusterSigningKeyIsAvailable) - tmcClusterToken := library.GetEnv(t, "PINNIPED_TMC_CLUSTER_TOKEN") + token := library.GetEnv(t, "PINNIPED_TEST_USER_TOKEN") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -69,7 +69,7 @@ func TestClient(t *testing.T) { // Using the CA bundle and host from the current (admin) kubeconfig, do the token exchange. clientConfig := library.NewClientConfig(t) - resp, err := client.ExchangeToken(ctx, tmcClusterToken, string(clientConfig.CAData), clientConfig.Host) + resp, err := client.ExchangeToken(ctx, token, string(clientConfig.CAData), clientConfig.Host) require.NoError(t, err) require.NotNil(t, resp.Status.ExpirationTimestamp) require.InDelta(t, time.Until(resp.Status.ExpirationTimestamp.Time), 1*time.Hour, float64(3*time.Minute)) diff --git a/test/integration/credentialrequest_test.go b/test/integration/credentialrequest_test.go index 1251a03e..cb9b0418 100644 --- a/test/integration/credentialrequest_test.go +++ b/test/integration/credentialrequest_test.go @@ -10,6 +10,7 @@ import ( "crypto/x509" "encoding/pem" "net/http" + "strings" "testing" "time" @@ -28,6 +29,10 @@ import ( func TestSuccessfulCredentialRequest(t *testing.T) { library.SkipUnlessIntegration(t) library.SkipUnlessClusterHasCapability(t, library.ClusterSigningKeyIsAvailable) + testUsername := library.GetEnv(t, "PINNIPED_TEST_USER_USERNAME") + expectedTestUserGroups := strings.Split( + strings.ReplaceAll(library.GetEnv(t, "PINNIPED_TEST_USER_GROUPS"), " ", ""), ",", + ) response, err := makeRequest(t, validCredentialRequestSpecWithRealToken(t)) require.NoError(t, err) @@ -39,6 +44,8 @@ func TestSuccessfulCredentialRequest(t *testing.T) { require.NotNil(t, response.Status.Credential) require.Empty(t, response.Status.Credential.Token) require.NotEmpty(t, response.Status.Credential.ClientCertificateData) + require.Equal(t, testUsername, getCommonName(t, response.Status.Credential.ClientCertificateData)) + require.ElementsMatch(t, expectedTestUserGroups, getOrganizations(t, response.Status.Credential.ClientCertificateData)) require.NotEmpty(t, response.Status.Credential.ClientKeyData) require.NotNil(t, response.Status.Credential.ExpirationTimestamp) require.InDelta(t, time.Until(response.Status.Credential.ExpirationTimestamp.Time), 1*time.Hour, float64(3*time.Minute)) @@ -65,7 +72,7 @@ func TestSuccessfulCredentialRequest(t *testing.T) { Subjects: []rbacv1.Subject{{ Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, - Name: getCommonName(t, response.Status.Credential.ClientCertificateData), + Name: testUsername, }}, RoleRef: rbacv1.RoleRef{ Kind: "ClusterRole", @@ -85,34 +92,37 @@ func TestSuccessfulCredentialRequest(t *testing.T) { require.NotEmpty(t, listNamespaceResponse.Items) }) - t.Run("access as group", func(t *testing.T) { - addTestClusterRoleBinding(ctx, t, adminClient, &rbacv1.ClusterRoleBinding{ - TypeMeta: metav1.TypeMeta{}, - ObjectMeta: metav1.ObjectMeta{ - Name: "integration-test-group-readonly-role-binding", - }, - Subjects: []rbacv1.Subject{{ - Kind: rbacv1.GroupKind, - APIGroup: rbacv1.GroupName, - Name: "tmc:member", - }}, - RoleRef: rbacv1.RoleRef{ - Kind: "ClusterRole", - APIGroup: rbacv1.GroupName, - Name: "view", - }, - }) + for _, group := range expectedTestUserGroups { + group := group + t.Run("access as group "+group, func(t *testing.T) { + addTestClusterRoleBinding(ctx, t, adminClient, &rbacv1.ClusterRoleBinding{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "integration-test-group-readonly-role-binding", + }, + Subjects: []rbacv1.Subject{{ + Kind: rbacv1.GroupKind, + APIGroup: rbacv1.GroupName, + Name: group, + }}, + RoleRef: rbacv1.RoleRef{ + Kind: "ClusterRole", + APIGroup: rbacv1.GroupName, + Name: "view", + }, + }) - // Use the client which is authenticated as the TMC group to list namespaces - var listNamespaceResponse *v1.NamespaceList - var canListNamespaces = func() bool { - listNamespaceResponse, err = clientWithCertFromCredentialRequest.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) - return err == nil - } - assert.Eventually(t, canListNamespaces, 3*time.Second, 250*time.Millisecond) - require.NoError(t, err) // prints out the error and stops the test in case of failure - require.NotEmpty(t, listNamespaceResponse.Items) - }) + // Use the client which is authenticated as the TMC group to list namespaces + var listNamespaceResponse *v1.NamespaceList + var canListNamespaces = func() bool { + listNamespaceResponse, err = clientWithCertFromCredentialRequest.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) + return err == nil + } + assert.Eventually(t, canListNamespaces, 3*time.Second, 250*time.Millisecond) + require.NoError(t, err) // prints out the error and stops the test in case of failure + require.NotEmpty(t, listNamespaceResponse.Items) + }) + } } func TestFailedCredentialRequestWhenTheRequestIsValidButTheTokenDoesNotAuthenticateTheUser(t *testing.T) { @@ -183,11 +193,10 @@ func makeRequest(t *testing.T, spec v1alpha1.CredentialRequestSpec) (*v1alpha1.C } func validCredentialRequestSpecWithRealToken(t *testing.T) v1alpha1.CredentialRequestSpec { - tmcClusterToken := library.GetEnv(t, "PINNIPED_TMC_CLUSTER_TOKEN") - + token := library.GetEnv(t, "PINNIPED_TEST_USER_TOKEN") return v1alpha1.CredentialRequestSpec{ Type: v1alpha1.TokenCredentialType, - Token: &v1alpha1.CredentialRequestTokenCredential{Value: tmcClusterToken}, + Token: &v1alpha1.CredentialRequestTokenCredential{Value: token}, } } @@ -224,3 +233,13 @@ func getCommonName(t *testing.T, certPEM string) string { return cert.Subject.CommonName } + +func getOrganizations(t *testing.T, certPEM string) []string { + t.Helper() + + pemBlock, _ := pem.Decode([]byte(certPEM)) + cert, err := x509.ParseCertificate(pemBlock.Bytes) + require.NoError(t, err) + + return cert.Subject.Organization +}