diff --git a/Dockerfile b/Dockerfile index d0f4f03e..eba31050 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/test-webhook/... # 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/test-webhook /usr/local/bin/test-webhook # Document the port EXPOSE 443 diff --git a/cmd/test-webhook/main.go b/cmd/test-webhook/main.go index 6ed214b4..b74809db 100644 --- a/cmd/test-webhook/main.go +++ b/cmd/test-webhook/main.go @@ -8,7 +8,7 @@ SPDX-License-Identifier: Apache-2.0 // 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 production settings. +// This webhook is NOT meant for use in production systems. package main import ( @@ -34,14 +34,24 @@ import ( restclient "k8s.io/client-go/rest" "k8s.io/klog/v2" + "github.com/suzerain-io/pinniped/internal/controller/apicerts" + "github.com/suzerain-io/pinniped/internal/controllerlib" "github.com/suzerain-io/pinniped/internal/provider" ) const ( - // namespace is the assumed namespace of this webhook. It is hardcoded now for - // simplicity, but should probably be made configurable in the future. + // This string must match the name of the Namespace declared in the deployment yaml. namespace = "test-webhook" + // This string must match the name of the Service declared in the deployment yaml. + serviceName = "test-webhook" + // TODO there must be a better way to get this specific json result string without needing to hardcode it + unauthenticatedResponse = `{"apiVersion":"authentication.k8s.io/v1beta1","kind":"TokenReview","status":{"authenticated":false}}` + + // TODO there must be a better way to get this specific json result string without needing to hardcode it + authenticatedResponseTemplate = `{"apiVersion":"authentication.k8s.io/v1beta1","kind":"TokenReview","status":{"authenticated":true,"user":{"username":"%s","uid":"%s","groups":%s}}}` + + singletonWorker = 1 defaultResyncInterval = 3 * time.Minute ) @@ -153,17 +163,21 @@ func (w *webhook) ServeHTTP(rsp http.ResponseWriter, req *http.Request) { ) == nil if !passwordMatches { respondWithUnauthenticated(rsp) - } - - groupsBuf := bytes.NewBuffer(secret.Data["groups"]) - gr := csv.NewReader(groupsBuf) - groups, err := gr.Read() - if err != nil { - klog.InfoS("could not read groups", "err", err) - rsp.WriteHeader(http.StatusInternalServerError) return } - trimSpace(groups) + + 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) + } respondWithAuthenticated(rsp, secret.ObjectMeta.Name, string(secret.UID), groups) } @@ -177,7 +191,7 @@ func contains(ss []string, s string) bool { return false } -func trimSpace(ss []string) { +func trimLeadingAndTrailingWhitespace(ss []string) { for i := range ss { ss[i] = strings.TrimSpace(ss[i]) } @@ -185,16 +199,7 @@ func trimSpace(ss []string) { func respondWithUnauthenticated(rsp http.ResponseWriter) { rsp.Header().Add("Content-Type", "application/json") - - body := authenticationv1.TokenReview{ - Status: authenticationv1.TokenReviewStatus{ - Authenticated: false, - }, - } - if err := json.NewEncoder(rsp).Encode(body); err != nil { - klog.InfoS("could not encode response", "err", err) - rsp.WriteHeader(http.StatusInternalServerError) - } + _, _ = rsp.Write([]byte(unauthenticatedResponse)) } func respondWithAuthenticated( @@ -203,21 +208,14 @@ func respondWithAuthenticated( groups []string, ) { rsp.Header().Add("Content-Type", "application/json") - - body := authenticationv1.TokenReview{ - Status: authenticationv1.TokenReviewStatus{ - Authenticated: true, - User: authenticationv1.UserInfo{ - Username: username, - UID: uid, - Groups: groups, - }, - }, - } - if err := json.NewEncoder(rsp).Encode(body); err != nil { + 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) { @@ -235,19 +233,52 @@ func newK8sClient() (kubernetes.Interface, error) { return kubeClient, nil } -func startControllers(ctx context.Context) error { - return 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, + "test-webhook 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( - provider.NewDynamicTLSServingCertProvider(), - secretInformer, - ).start(ctx, l) + return newWebhook(dynamicCertProvider, secretInformer).start(ctx, l) } func waitForSignal() os.Signal { @@ -271,28 +302,26 @@ func run() error { kubeinformers.WithNamespace(namespace), ) - if err := startControllers(ctx); err != nil { - return fmt.Errorf("cannot start controllers: %w", err) - } + dynamicCertProvider := provider.NewDynamicTLSServingCertProvider() + + startControllers(ctx, dynamicCertProvider, kubeClient, kubeInformers) klog.InfoS("controllers are ready") - l, err := net.Listen("tcp", "127.0.0.1:443") + //nolint: gosec + l, err := net.Listen("tcp", ":443") if err != nil { return fmt.Errorf("cannot create listener: %w", err) } defer l.Close() - if err := startWebhook( - ctx, - l, - kubeInformers.Core().V1().Secrets(), - ); err != nil { + 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()) - signal := waitForSignal() - klog.InfoS("webhook exiting", "signal", signal) + gotSignal := waitForSignal() + klog.InfoS("webhook exiting", "signal", gotSignal) return nil } diff --git a/cmd/test-webhook/main_test.go b/cmd/test-webhook/main_test.go index 82e434c6..699bc24d 100644 --- a/cmd/test-webhook/main_test.go +++ b/cmd/test-webhook/main_test.go @@ -19,6 +19,7 @@ import ( "net/http" "net/url" "reflect" + "strings" "testing" "time" @@ -43,60 +44,48 @@ func TestWebhook(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - uid, otherUID, colonUID := "some-uid", "some-other-uid", "some-colon-uid" - user, otherUser, colonUser := "some-user", "some-other-user", "some-colon-user" - password, otherPassword, colonPassword := "some-password", "some-other-password", "some-:-password" + 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" - - passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) - require.NoError(t, err) - - otherPasswordHash, err := bcrypt.GenerateFromPassword([]byte(otherPassword), bcrypt.MinCost) - require.NoError(t, err) - - colonPasswordHash, err := bcrypt.GenerateFromPassword([]byte(colonPassword), bcrypt.MinCost) - require.NoError(t, err) - groups := group0 + " , " + group1 - userSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - UID: types.UID(uid), - Name: user, - Namespace: "test-webhook", - }, - Data: map[string][]byte{ - "passwordHash": passwordHash, - "groups": []byte(groups), - }, - } - otherUserSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - UID: types.UID(otherUID), - Name: otherUser, - Namespace: "test-webhook", - }, - Data: map[string][]byte{ - "passwordHash": otherPasswordHash, - "groups": []byte(groups), - }, - } - colonUserSecret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - UID: types.UID(colonUID), - Name: colonUser, - Namespace: "test-webhook", - }, - Data: map[string][]byte{ - "passwordHash": colonPasswordHash, - "groups": []byte(groups), - }, - } - kubeClient := kubernetesfake.NewSimpleClientset() - require.NoError(t, kubeClient.Tracker().Add(userSecret)) - require.NoError(t, kubeClient.Tracker().Add(otherUserSecret)) - require.NoError(t, kubeClient.Tracker().Add(colonUserSecret)) + 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: "test-webhook", + }, + 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: "test-webhook", + }, + Data: map[string][]byte{ + "passwordHash": undefinedGroupsUserPasswordHash, + }, + })) secretInformer := createSecretInformer(t, kubeClient) @@ -110,6 +99,12 @@ func TestWebhook(t *testing.T) { client := newClient(caBundle, serverName) + goodURL := fmt.Sprintf("https://%s/authenticate", l.Addr().String()) + goodRequestHeaders := map[string][]string{ + "Content-Type": {"application/json"}, + "Accept": {"application/json"}, + } + tests := []struct { name string url string @@ -119,178 +114,187 @@ func TestWebhook(t *testing.T) { wantStatus int wantHeaders map[string][]string - wantBody *authenticationv1.TokenReview + wantBody *string }{ { - name: "success", - url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), - method: http.MethodPost, - headers: map[string][]string{ - "Content-Type": []string{"application/json"}, - "Accept": []string{"application/json"}, - }, + 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": []string{"application/json"}, - }, - wantBody: &authenticationv1.TokenReview{ - Status: authenticationv1.TokenReviewStatus{ - Authenticated: true, - User: authenticationv1.UserInfo{ - Username: user, - UID: uid, - Groups: []string{group0, group1}, - }, - }, + "Content-Type": {"application/json"}, }, + wantBody: authenticatedResponseJSON(user, uid, []string{group0, group1}), }, { - name: "wrong username for password", - url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), - method: http.MethodPost, - headers: map[string][]string{ - "Content-Type": []string{"application/json"}, - "Accept": []string{"application/json"}, + 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": []string{"application/json"}, - }, - wantBody: &authenticationv1.TokenReview{ - Status: authenticationv1.TokenReviewStatus{ - Authenticated: false, - }, + "Content-Type": {"application/json"}, }, + wantBody: unauthenticatedResponseJSON(), }, { - name: "wrong password for username", - url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), - method: http.MethodPost, - headers: map[string][]string{ - "Content-Type": []string{"application/json"}, - "Accept": []string{"application/json"}, + 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": []string{"application/json"}, - }, - wantBody: &authenticationv1.TokenReview{ - Status: authenticationv1.TokenReviewStatus{ - Authenticated: false, - }, + "Content-Type": {"application/json"}, }, + wantBody: unauthenticatedResponseJSON(), }, { - name: "non-existent password for username", - url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), - method: http.MethodPost, - headers: map[string][]string{ - "Content-Type": []string{"application/json"}, - "Accept": []string{"application/json"}, - }, + 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": []string{"application/json"}, - }, - wantBody: &authenticationv1.TokenReview{ - Status: authenticationv1.TokenReviewStatus{ - Authenticated: false, - }, + "Content-Type": {"application/json"}, }, + wantBody: unauthenticatedResponseJSON(), }, { - name: "non-existent username", - url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), - method: http.MethodPost, - headers: map[string][]string{ - "Content-Type": []string{"application/json"}, - "Accept": []string{"application/json"}, - }, + 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": []string{"application/json"}, - }, - wantBody: &authenticationv1.TokenReview{ - Status: authenticationv1.TokenReviewStatus{ - Authenticated: false, - }, + "Content-Type": {"application/json"}, }, + wantBody: unauthenticatedResponseJSON(), }, { - name: "invalid token (missing colon)", - url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), - method: http.MethodPost, - headers: map[string][]string{ - "Content-Type": []string{"application/json"}, - "Accept": []string{"application/json"}, - }, + 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: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), - method: http.MethodPost, - headers: map[string][]string{ - "Content-Type": []string{"application/json"}, - "Accept": []string{"application/json"}, - }, + 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": []string{"application/json"}, - }, - wantBody: &authenticationv1.TokenReview{ - Status: authenticationv1.TokenReviewStatus{ - Authenticated: true, - User: authenticationv1.UserInfo{ - Username: colonUser, - UID: colonUID, - Groups: []string{group0, group1}, - }, - }, + "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: map[string][]string{ - "Content-Type": []string{"application/json"}, - "Accept": []string{"application/json"}, - }, + 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: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), - method: http.MethodGet, - headers: map[string][]string{ - "Content-Type": []string{"application/json"}, - "Accept": []string{"application/json"}, - }, + name: "bad method", + url: goodURL, + method: http.MethodGet, + headers: goodRequestHeaders, body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-token") }, @@ -298,11 +302,11 @@ func TestWebhook(t *testing.T) { }, { name: "bad content type", - url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), + url: goodURL, method: http.MethodPost, headers: map[string][]string{ - "Content-Type": []string{"application/xml"}, - "Accept": []string{"application/json"}, + "Content-Type": {"application/xml"}, + "Accept": {"application/json"}, }, body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-token") @@ -311,11 +315,11 @@ func TestWebhook(t *testing.T) { }, { name: "bad accept", - url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), + url: goodURL, method: http.MethodPost, headers: map[string][]string{ - "Content-Type": []string{"application/json"}, - "Accept": []string{"application/xml"}, + "Content-Type": {"application/json"}, + "Accept": {"application/xml"}, }, body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-token") @@ -323,23 +327,21 @@ func TestWebhook(t *testing.T) { wantStatus: http.StatusUnsupportedMediaType, }, { - name: "bad body", - url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()), - method: http.MethodPost, - headers: map[string][]string{ - "Content-Type": []string{"application/json"}, - "Accept": []string{"application/json"}, - }, + 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) { - url, err := url.Parse(test.url) + parsedURL, err := url.Parse(test.url) require.NoError(t, err) body, err := test.body() @@ -347,25 +349,28 @@ func TestWebhook(t *testing.T) { rsp, err := client.Do(&http.Request{ Method: test.method, - URL: url, + URL: parsedURL, Header: test.headers, Body: body, }) require.NoError(t, err) defer rsp.Body.Close() - if test.wantStatus != 0 { - require.Equal(t, test.wantStatus, rsp.StatusCode) - } + 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 { - rspBody, err := newTokenReview(rsp.Body) require.NoError(t, err) - require.Equal(t, test.wantBody, rspBody) + require.JSONEq(t, *test.wantBody, string(responseBody)) + } else { + require.Empty(t, responseBody) } }) } @@ -448,10 +453,57 @@ func newTokenReviewBody(token string) (io.ReadCloser, error) { return ioutil.NopCloser(buf), err } -// newTokenReview reads a JSON-encoded authenticationv1.TokenReview from an -// io.Reader. -func newTokenReview(body io.Reader) (*authenticationv1.TokenReview, error) { - var tr authenticationv1.TokenReview - err := json.NewDecoder(body).Decode(&tr) - return &tr, 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 { + var quotedGroups []string + for _, group := range groups { + quotedGroups = append(quotedGroups, `"`+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: "test-webhook", + }, + Data: map[string][]byte{ + "passwordHash": passwordHash, + "groups": []byte(groups), + }, + } + + require.NoError(t, kubeClient.Tracker().Add(secret)) } diff --git a/deploy-test-webhook/README.md b/deploy-test-webhook/README.md new file mode 100644 index 00000000..f802fece --- /dev/null +++ b/deploy-test-webhook/README.md @@ -0,0 +1,110 @@ +# Deploying `test-webhook` + +## What is `test-webhook`? + +The `test-webhook` 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 `test-webhook` identity provider. Note that this is not recommended for +production use. + +The `test-webhook` 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). + +## 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-test-webhook` 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 test-webhook --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 `test-webhook` 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 test-webhook \ + --from-literal=groups=group1,group2 \ + --from-literal=passwordHash=$(htpasswd -nbBC 16 x password123 | sed -e "s/^x://") +``` + +### Get the `test-webhook` App's Auto-Generated Certificate Authority Bundle + +Fetch the auto-generated CA bundle for the `test-webhook`'s HTTP TLS endpoint. + +```bash +kubectl get secret api-serving-cert --namespace test-webhook \ + -o jsonpath={.data.caCertificate} \ + | base64 -d \ + | tee /tmp/test-webhook-ca +``` + +### Configuring Pinniped to Use `test-webhook` as an Identity Provider + +When installing Pinniped on the same cluster, configure `test-webhook` as an Identity Provider for Pinniped +using the webhook URL `https://test-webhook.test-webhook.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/test-webhook-ca curlpod:/tmp/test-webhook-ca + ``` + + 1. Run a `curl` command to try to authenticate as the user created above. + + ```bash + kubectl -it exec curlpod -- curl https://test-webhook.test-webhook.svc/authenticate \ + --cacert /tmp/test-webhook-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-test-webhook/deployment.yaml b/deploy-test-webhook/deployment.yaml new file mode 100644 index 00000000..b9a794a2 --- /dev/null +++ b/deploy-test-webhook/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: test-webhook + labels: + name: test-webhook +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: test-webhook-service-account + namespace: test-webhook +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: test-webhook + namespace: test-webhook + labels: + app: test-webhook +spec: + replicas: 1 + selector: + matchLabels: + app: test-webhook + template: + metadata: + labels: + app: test-webhook + spec: + serviceAccountName: test-webhook-service-account + containers: + - name: test-webhook + #@ 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/test-webhook +--- +apiVersion: v1 +kind: Service +metadata: + name: test-webhook + namespace: test-webhook + labels: + app: test-webhook +spec: + type: ClusterIP + selector: + app: test-webhook + ports: + - protocol: TCP + port: 443 + targetPort: 443 diff --git a/deploy-test-webhook/rbac.yaml b/deploy-test-webhook/rbac.yaml new file mode 100644 index 00000000..a111c5a1 --- /dev/null +++ b/deploy-test-webhook/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: test-webhook-aggregated-api-server-role + namespace: test-webhook +rules: + - apiGroups: [""] + resources: [secrets] + verbs: [create, get, list, patch, update, watch] +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: test-webhook-aggregated-api-server-role-binding + namespace: test-webhook +subjects: + - kind: ServiceAccount + name: test-webhook-service-account + namespace: test-webhook +roleRef: + kind: Role + name: test-webhook-aggregated-api-server-role + apiGroup: rbac.authorization.k8s.io diff --git a/deploy-test-webhook/values.yaml b/deploy-test-webhook/values.yaml new file mode 100644 index 00000000..d8531a2c --- /dev/null +++ b/deploy-test-webhook/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..2e6cf752 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 [../deplot-test-webhook/README.md](../deplot-test-webhook/README.md) +for details. + ## Tools This example deployment uses `ytt` from [Carvel](https://carvel.dev/) to template the YAML files.