Create a deployment for test-webhook
- For now, build the test-webhook binary in the same container image as the pinniped-server binary, to make it easier to distribute - Also fix lots of bugs from the first draft of the test-webhook's `/authenticate` implementation from the previous commit - Add a detailed README for the new deploy-test-webhook directory
This commit is contained in:
parent
3ee7a0d881
commit
2565f67824
@ -19,13 +19,16 @@ COPY tools ./tools
|
|||||||
COPY hack ./hack
|
COPY hack ./hack
|
||||||
|
|
||||||
# Build the executable binary (CGO_ENABLED=0 means static linking)
|
# 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
|
# Use a runtime image based on Debian slim
|
||||||
FROM debian:10.5-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/pinniped-server /usr/local/bin/pinniped-server
|
||||||
|
COPY --from=build-env /work/out/test-webhook /usr/local/bin/test-webhook
|
||||||
|
|
||||||
# Document the port
|
# Document the port
|
||||||
EXPOSE 443
|
EXPOSE 443
|
||||||
|
@ -8,7 +8,7 @@ SPDX-License-Identifier: Apache-2.0
|
|||||||
// This webhook is meant to be used in demo settings to play around with
|
// 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.
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -34,14 +34,24 @@ import (
|
|||||||
restclient "k8s.io/client-go/rest"
|
restclient "k8s.io/client-go/rest"
|
||||||
"k8s.io/klog/v2"
|
"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"
|
"github.com/suzerain-io/pinniped/internal/provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// namespace is the assumed namespace of this webhook. It is hardcoded now for
|
// This string must match the name of the Namespace declared in the deployment yaml.
|
||||||
// simplicity, but should probably be made configurable in the future.
|
|
||||||
namespace = "test-webhook"
|
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
|
defaultResyncInterval = 3 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -153,17 +163,21 @@ func (w *webhook) ServeHTTP(rsp http.ResponseWriter, req *http.Request) {
|
|||||||
) == nil
|
) == nil
|
||||||
if !passwordMatches {
|
if !passwordMatches {
|
||||||
respondWithUnauthenticated(rsp)
|
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
|
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)
|
respondWithAuthenticated(rsp, secret.ObjectMeta.Name, string(secret.UID), groups)
|
||||||
}
|
}
|
||||||
@ -177,7 +191,7 @@ func contains(ss []string, s string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func trimSpace(ss []string) {
|
func trimLeadingAndTrailingWhitespace(ss []string) {
|
||||||
for i := range ss {
|
for i := range ss {
|
||||||
ss[i] = strings.TrimSpace(ss[i])
|
ss[i] = strings.TrimSpace(ss[i])
|
||||||
}
|
}
|
||||||
@ -185,16 +199,7 @@ func trimSpace(ss []string) {
|
|||||||
|
|
||||||
func respondWithUnauthenticated(rsp http.ResponseWriter) {
|
func respondWithUnauthenticated(rsp http.ResponseWriter) {
|
||||||
rsp.Header().Add("Content-Type", "application/json")
|
rsp.Header().Add("Content-Type", "application/json")
|
||||||
|
_, _ = rsp.Write([]byte(unauthenticatedResponse))
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func respondWithAuthenticated(
|
func respondWithAuthenticated(
|
||||||
@ -203,21 +208,14 @@ func respondWithAuthenticated(
|
|||||||
groups []string,
|
groups []string,
|
||||||
) {
|
) {
|
||||||
rsp.Header().Add("Content-Type", "application/json")
|
rsp.Header().Add("Content-Type", "application/json")
|
||||||
|
groupsJSONBytes, err := json.Marshal(groups)
|
||||||
body := authenticationv1.TokenReview{
|
if err != nil {
|
||||||
Status: authenticationv1.TokenReviewStatus{
|
|
||||||
Authenticated: true,
|
|
||||||
User: authenticationv1.UserInfo{
|
|
||||||
Username: username,
|
|
||||||
UID: uid,
|
|
||||||
Groups: groups,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if err := json.NewEncoder(rsp).Encode(body); err != nil {
|
|
||||||
klog.InfoS("could not encode response", "err", err)
|
klog.InfoS("could not encode response", "err", err)
|
||||||
rsp.WriteHeader(http.StatusInternalServerError)
|
rsp.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
jsonBody := fmt.Sprintf(authenticatedResponseTemplate, username, uid, groupsJSONBytes)
|
||||||
|
_, _ = rsp.Write([]byte(jsonBody))
|
||||||
}
|
}
|
||||||
|
|
||||||
func newK8sClient() (kubernetes.Interface, error) {
|
func newK8sClient() (kubernetes.Interface, error) {
|
||||||
@ -235,19 +233,52 @@ func newK8sClient() (kubernetes.Interface, error) {
|
|||||||
return kubeClient, nil
|
return kubeClient, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func startControllers(ctx context.Context) error {
|
func startControllers(
|
||||||
return nil
|
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(
|
func startWebhook(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
l net.Listener,
|
l net.Listener,
|
||||||
|
dynamicCertProvider provider.DynamicTLSServingCertProvider,
|
||||||
secretInformer corev1informers.SecretInformer,
|
secretInformer corev1informers.SecretInformer,
|
||||||
) error {
|
) error {
|
||||||
return newWebhook(
|
return newWebhook(dynamicCertProvider, secretInformer).start(ctx, l)
|
||||||
provider.NewDynamicTLSServingCertProvider(),
|
|
||||||
secretInformer,
|
|
||||||
).start(ctx, l)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForSignal() os.Signal {
|
func waitForSignal() os.Signal {
|
||||||
@ -271,28 +302,26 @@ func run() error {
|
|||||||
kubeinformers.WithNamespace(namespace),
|
kubeinformers.WithNamespace(namespace),
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := startControllers(ctx); err != nil {
|
dynamicCertProvider := provider.NewDynamicTLSServingCertProvider()
|
||||||
return fmt.Errorf("cannot start controllers: %w", err)
|
|
||||||
}
|
startControllers(ctx, dynamicCertProvider, kubeClient, kubeInformers)
|
||||||
klog.InfoS("controllers are ready")
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot create listener: %w", err)
|
return fmt.Errorf("cannot create listener: %w", err)
|
||||||
}
|
}
|
||||||
defer l.Close()
|
defer l.Close()
|
||||||
|
|
||||||
if err := startWebhook(
|
err = startWebhook(ctx, l, dynamicCertProvider, kubeInformers.Core().V1().Secrets())
|
||||||
ctx,
|
if err != nil {
|
||||||
l,
|
|
||||||
kubeInformers.Core().V1().Secrets(),
|
|
||||||
); err != nil {
|
|
||||||
return fmt.Errorf("cannot start webhook: %w", err)
|
return fmt.Errorf("cannot start webhook: %w", err)
|
||||||
}
|
}
|
||||||
klog.InfoS("webhook is ready", "address", l.Addr().String())
|
klog.InfoS("webhook is ready", "address", l.Addr().String())
|
||||||
|
|
||||||
signal := waitForSignal()
|
gotSignal := waitForSignal()
|
||||||
klog.InfoS("webhook exiting", "signal", signal)
|
klog.InfoS("webhook exiting", "signal", gotSignal)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -43,60 +44,48 @@ func TestWebhook(t *testing.T) {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
uid, otherUID, colonUID := "some-uid", "some-other-uid", "some-colon-uid"
|
user, otherUser, colonUser, noGroupUser, oneGroupUser, passwordUndefinedUser, emptyPasswordUser, underfinedGroupsUser :=
|
||||||
user, otherUser, colonUser := "some-user", "some-other-user", "some-colon-user"
|
"some-user", "other-user", "colon-user", "no-group-user", "one-group-user", "password-undefined-user", "empty-password-user", "undefined-groups-user"
|
||||||
password, otherPassword, colonPassword := "some-password", "some-other-password", "some-:-password"
|
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"
|
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
|
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()
|
kubeClient := kubernetesfake.NewSimpleClientset()
|
||||||
require.NoError(t, kubeClient.Tracker().Add(userSecret))
|
addSecretToFakeClientTracker(t, kubeClient, user, uid, password, groups)
|
||||||
require.NoError(t, kubeClient.Tracker().Add(otherUserSecret))
|
addSecretToFakeClientTracker(t, kubeClient, otherUser, otherUID, otherPassword, groups)
|
||||||
require.NoError(t, kubeClient.Tracker().Add(colonUserSecret))
|
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)
|
secretInformer := createSecretInformer(t, kubeClient)
|
||||||
|
|
||||||
@ -110,6 +99,12 @@ func TestWebhook(t *testing.T) {
|
|||||||
|
|
||||||
client := newClient(caBundle, serverName)
|
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 {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
url string
|
url string
|
||||||
@ -119,178 +114,187 @@ func TestWebhook(t *testing.T) {
|
|||||||
|
|
||||||
wantStatus int
|
wantStatus int
|
||||||
wantHeaders map[string][]string
|
wantHeaders map[string][]string
|
||||||
wantBody *authenticationv1.TokenReview
|
wantBody *string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "success",
|
name: "success for a user who belongs to multiple groups",
|
||||||
url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()),
|
url: goodURL,
|
||||||
method: http.MethodPost,
|
method: http.MethodPost,
|
||||||
headers: map[string][]string{
|
headers: goodRequestHeaders,
|
||||||
"Content-Type": []string{"application/json"},
|
|
||||||
"Accept": []string{"application/json"},
|
|
||||||
},
|
|
||||||
body: func() (io.ReadCloser, error) {
|
body: func() (io.ReadCloser, error) {
|
||||||
return newTokenReviewBody(user + ":" + password)
|
return newTokenReviewBody(user + ":" + password)
|
||||||
},
|
},
|
||||||
wantStatus: http.StatusOK,
|
wantStatus: http.StatusOK,
|
||||||
wantHeaders: map[string][]string{
|
wantHeaders: map[string][]string{
|
||||||
"Content-Type": []string{"application/json"},
|
"Content-Type": {"application/json"},
|
||||||
},
|
|
||||||
wantBody: &authenticationv1.TokenReview{
|
|
||||||
Status: authenticationv1.TokenReviewStatus{
|
|
||||||
Authenticated: true,
|
|
||||||
User: authenticationv1.UserInfo{
|
|
||||||
Username: user,
|
|
||||||
UID: uid,
|
|
||||||
Groups: []string{group0, group1},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
wantBody: authenticatedResponseJSON(user, uid, []string{group0, group1}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "wrong username for password",
|
name: "success for a user who belongs to one groups",
|
||||||
url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()),
|
url: goodURL,
|
||||||
method: http.MethodPost,
|
method: http.MethodPost,
|
||||||
headers: map[string][]string{
|
headers: goodRequestHeaders,
|
||||||
"Content-Type": []string{"application/json"},
|
body: func() (io.ReadCloser, error) {
|
||||||
"Accept": []string{"application/json"},
|
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) {
|
body: func() (io.ReadCloser, error) {
|
||||||
return newTokenReviewBody(otherUser + ":" + password)
|
return newTokenReviewBody(otherUser + ":" + password)
|
||||||
},
|
},
|
||||||
wantStatus: http.StatusOK,
|
wantStatus: http.StatusOK,
|
||||||
wantHeaders: map[string][]string{
|
wantHeaders: map[string][]string{
|
||||||
"Content-Type": []string{"application/json"},
|
"Content-Type": {"application/json"},
|
||||||
},
|
|
||||||
wantBody: &authenticationv1.TokenReview{
|
|
||||||
Status: authenticationv1.TokenReviewStatus{
|
|
||||||
Authenticated: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
wantBody: unauthenticatedResponseJSON(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "wrong password for username",
|
name: "when a user has no password hash in the secret",
|
||||||
url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()),
|
url: goodURL,
|
||||||
method: http.MethodPost,
|
method: http.MethodPost,
|
||||||
headers: map[string][]string{
|
headers: goodRequestHeaders,
|
||||||
"Content-Type": []string{"application/json"},
|
body: func() (io.ReadCloser, error) {
|
||||||
"Accept": []string{"application/json"},
|
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) {
|
body: func() (io.ReadCloser, error) {
|
||||||
return newTokenReviewBody(user + ":" + otherPassword)
|
return newTokenReviewBody(user + ":" + otherPassword)
|
||||||
},
|
},
|
||||||
wantStatus: http.StatusOK,
|
wantStatus: http.StatusOK,
|
||||||
wantHeaders: map[string][]string{
|
wantHeaders: map[string][]string{
|
||||||
"Content-Type": []string{"application/json"},
|
"Content-Type": {"application/json"},
|
||||||
},
|
|
||||||
wantBody: &authenticationv1.TokenReview{
|
|
||||||
Status: authenticationv1.TokenReviewStatus{
|
|
||||||
Authenticated: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
wantBody: unauthenticatedResponseJSON(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "non-existent password for username",
|
name: "non-existent password for username",
|
||||||
url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()),
|
url: goodURL,
|
||||||
method: http.MethodPost,
|
method: http.MethodPost,
|
||||||
headers: map[string][]string{
|
headers: goodRequestHeaders,
|
||||||
"Content-Type": []string{"application/json"},
|
|
||||||
"Accept": []string{"application/json"},
|
|
||||||
},
|
|
||||||
body: func() (io.ReadCloser, error) {
|
body: func() (io.ReadCloser, error) {
|
||||||
return newTokenReviewBody(user + ":" + "some-non-existent-password")
|
return newTokenReviewBody(user + ":" + "some-non-existent-password")
|
||||||
},
|
},
|
||||||
wantStatus: http.StatusOK,
|
wantStatus: http.StatusOK,
|
||||||
wantHeaders: map[string][]string{
|
wantHeaders: map[string][]string{
|
||||||
"Content-Type": []string{"application/json"},
|
"Content-Type": {"application/json"},
|
||||||
},
|
|
||||||
wantBody: &authenticationv1.TokenReview{
|
|
||||||
Status: authenticationv1.TokenReviewStatus{
|
|
||||||
Authenticated: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
wantBody: unauthenticatedResponseJSON(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "non-existent username",
|
name: "non-existent username",
|
||||||
url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()),
|
url: goodURL,
|
||||||
method: http.MethodPost,
|
method: http.MethodPost,
|
||||||
headers: map[string][]string{
|
headers: goodRequestHeaders,
|
||||||
"Content-Type": []string{"application/json"},
|
|
||||||
"Accept": []string{"application/json"},
|
|
||||||
},
|
|
||||||
body: func() (io.ReadCloser, error) {
|
body: func() (io.ReadCloser, error) {
|
||||||
return newTokenReviewBody("some-non-existent-user" + ":" + password)
|
return newTokenReviewBody("some-non-existent-user" + ":" + password)
|
||||||
},
|
},
|
||||||
wantStatus: http.StatusOK,
|
wantStatus: http.StatusOK,
|
||||||
wantHeaders: map[string][]string{
|
wantHeaders: map[string][]string{
|
||||||
"Content-Type": []string{"application/json"},
|
"Content-Type": {"application/json"},
|
||||||
},
|
|
||||||
wantBody: &authenticationv1.TokenReview{
|
|
||||||
Status: authenticationv1.TokenReviewStatus{
|
|
||||||
Authenticated: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
wantBody: unauthenticatedResponseJSON(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid token (missing colon)",
|
name: "bad token format (missing colon)",
|
||||||
url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()),
|
url: goodURL,
|
||||||
method: http.MethodPost,
|
method: http.MethodPost,
|
||||||
headers: map[string][]string{
|
headers: goodRequestHeaders,
|
||||||
"Content-Type": []string{"application/json"},
|
|
||||||
"Accept": []string{"application/json"},
|
|
||||||
},
|
|
||||||
body: func() (io.ReadCloser, error) {
|
body: func() (io.ReadCloser, error) {
|
||||||
return newTokenReviewBody(user)
|
return newTokenReviewBody(user)
|
||||||
},
|
},
|
||||||
wantStatus: http.StatusBadRequest,
|
wantStatus: http.StatusBadRequest,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "password contains colon",
|
name: "password contains colon",
|
||||||
url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()),
|
url: goodURL,
|
||||||
method: http.MethodPost,
|
method: http.MethodPost,
|
||||||
headers: map[string][]string{
|
headers: goodRequestHeaders,
|
||||||
"Content-Type": []string{"application/json"},
|
|
||||||
"Accept": []string{"application/json"},
|
|
||||||
},
|
|
||||||
body: func() (io.ReadCloser, error) {
|
body: func() (io.ReadCloser, error) {
|
||||||
return newTokenReviewBody(colonUser + ":" + colonPassword)
|
return newTokenReviewBody(colonUser + ":" + colonPassword)
|
||||||
},
|
},
|
||||||
wantStatus: http.StatusOK,
|
wantStatus: http.StatusOK,
|
||||||
wantHeaders: map[string][]string{
|
wantHeaders: map[string][]string{
|
||||||
"Content-Type": []string{"application/json"},
|
"Content-Type": {"application/json"},
|
||||||
},
|
|
||||||
wantBody: &authenticationv1.TokenReview{
|
|
||||||
Status: authenticationv1.TokenReviewStatus{
|
|
||||||
Authenticated: true,
|
|
||||||
User: authenticationv1.UserInfo{
|
|
||||||
Username: colonUser,
|
|
||||||
UID: colonUID,
|
|
||||||
Groups: []string{group0, group1},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
wantBody: authenticatedResponseJSON(colonUser, colonUID, []string{group0, group1}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "bad path",
|
name: "bad path",
|
||||||
url: fmt.Sprintf("https://%s/tuna", l.Addr().String()),
|
url: fmt.Sprintf("https://%s/tuna", l.Addr().String()),
|
||||||
method: http.MethodPost,
|
method: http.MethodPost,
|
||||||
headers: map[string][]string{
|
headers: goodRequestHeaders,
|
||||||
"Content-Type": []string{"application/json"},
|
|
||||||
"Accept": []string{"application/json"},
|
|
||||||
},
|
|
||||||
body: func() (io.ReadCloser, error) {
|
body: func() (io.ReadCloser, error) {
|
||||||
return newTokenReviewBody("some-token")
|
return newTokenReviewBody("some-token")
|
||||||
},
|
},
|
||||||
wantStatus: http.StatusNotFound,
|
wantStatus: http.StatusNotFound,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "bad method",
|
name: "bad method",
|
||||||
url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()),
|
url: goodURL,
|
||||||
method: http.MethodGet,
|
method: http.MethodGet,
|
||||||
headers: map[string][]string{
|
headers: goodRequestHeaders,
|
||||||
"Content-Type": []string{"application/json"},
|
|
||||||
"Accept": []string{"application/json"},
|
|
||||||
},
|
|
||||||
body: func() (io.ReadCloser, error) {
|
body: func() (io.ReadCloser, error) {
|
||||||
return newTokenReviewBody("some-token")
|
return newTokenReviewBody("some-token")
|
||||||
},
|
},
|
||||||
@ -298,11 +302,11 @@ func TestWebhook(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "bad content type",
|
name: "bad content type",
|
||||||
url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()),
|
url: goodURL,
|
||||||
method: http.MethodPost,
|
method: http.MethodPost,
|
||||||
headers: map[string][]string{
|
headers: map[string][]string{
|
||||||
"Content-Type": []string{"application/xml"},
|
"Content-Type": {"application/xml"},
|
||||||
"Accept": []string{"application/json"},
|
"Accept": {"application/json"},
|
||||||
},
|
},
|
||||||
body: func() (io.ReadCloser, error) {
|
body: func() (io.ReadCloser, error) {
|
||||||
return newTokenReviewBody("some-token")
|
return newTokenReviewBody("some-token")
|
||||||
@ -311,11 +315,11 @@ func TestWebhook(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "bad accept",
|
name: "bad accept",
|
||||||
url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()),
|
url: goodURL,
|
||||||
method: http.MethodPost,
|
method: http.MethodPost,
|
||||||
headers: map[string][]string{
|
headers: map[string][]string{
|
||||||
"Content-Type": []string{"application/json"},
|
"Content-Type": {"application/json"},
|
||||||
"Accept": []string{"application/xml"},
|
"Accept": {"application/xml"},
|
||||||
},
|
},
|
||||||
body: func() (io.ReadCloser, error) {
|
body: func() (io.ReadCloser, error) {
|
||||||
return newTokenReviewBody("some-token")
|
return newTokenReviewBody("some-token")
|
||||||
@ -323,23 +327,21 @@ func TestWebhook(t *testing.T) {
|
|||||||
wantStatus: http.StatusUnsupportedMediaType,
|
wantStatus: http.StatusUnsupportedMediaType,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "bad body",
|
name: "bad body",
|
||||||
url: fmt.Sprintf("https://%s/authenticate", l.Addr().String()),
|
url: goodURL,
|
||||||
method: http.MethodPost,
|
method: http.MethodPost,
|
||||||
headers: map[string][]string{
|
headers: goodRequestHeaders,
|
||||||
"Content-Type": []string{"application/json"},
|
|
||||||
"Accept": []string{"application/json"},
|
|
||||||
},
|
|
||||||
body: func() (io.ReadCloser, error) {
|
body: func() (io.ReadCloser, error) {
|
||||||
return ioutil.NopCloser(bytes.NewBuffer([]byte("invalid body"))), nil
|
return ioutil.NopCloser(bytes.NewBuffer([]byte("invalid body"))), nil
|
||||||
},
|
},
|
||||||
wantStatus: http.StatusBadRequest,
|
wantStatus: http.StatusBadRequest,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
test := test
|
test := test
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
url, err := url.Parse(test.url)
|
parsedURL, err := url.Parse(test.url)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
body, err := test.body()
|
body, err := test.body()
|
||||||
@ -347,25 +349,28 @@ func TestWebhook(t *testing.T) {
|
|||||||
|
|
||||||
rsp, err := client.Do(&http.Request{
|
rsp, err := client.Do(&http.Request{
|
||||||
Method: test.method,
|
Method: test.method,
|
||||||
URL: url,
|
URL: parsedURL,
|
||||||
Header: test.headers,
|
Header: test.headers,
|
||||||
Body: body,
|
Body: body,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer rsp.Body.Close()
|
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 {
|
if test.wantHeaders != nil {
|
||||||
for k, v := range test.wantHeaders {
|
for k, v := range test.wantHeaders {
|
||||||
require.Equal(t, v, rsp.Header.Values(k))
|
require.Equal(t, v, rsp.Header.Values(k))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
responseBody, err := ioutil.ReadAll(rsp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
if test.wantBody != nil {
|
if test.wantBody != nil {
|
||||||
rspBody, err := newTokenReview(rsp.Body)
|
|
||||||
require.NoError(t, err)
|
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
|
return ioutil.NopCloser(buf), err
|
||||||
}
|
}
|
||||||
|
|
||||||
// newTokenReview reads a JSON-encoded authenticationv1.TokenReview from an
|
func unauthenticatedResponseJSON() *string {
|
||||||
// io.Reader.
|
// Very specific expected result. Avoid using json package so we know exactly what we're asserting.
|
||||||
func newTokenReview(body io.Reader) (*authenticationv1.TokenReview, error) {
|
s := `{
|
||||||
var tr authenticationv1.TokenReview
|
"apiVersion": "authentication.k8s.io/v1beta1",
|
||||||
err := json.NewDecoder(body).Decode(&tr)
|
"kind": "TokenReview",
|
||||||
return &tr, err
|
"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))
|
||||||
}
|
}
|
||||||
|
110
deploy-test-webhook/README.md
Normal file
110
deploy-test-webhook/README.md
Normal file
@ -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
|
||||||
|
```
|
63
deploy-test-webhook/deployment.yaml
Normal file
63
deploy-test-webhook/deployment.yaml
Normal file
@ -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
|
30
deploy-test-webhook/rbac.yaml
Normal file
30
deploy-test-webhook/rbac.yaml
Normal file
@ -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
|
10
deploy-test-webhook/values.yaml
Normal file
10
deploy-test-webhook/values.yaml
Normal file
@ -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
|
@ -1,5 +1,12 @@
|
|||||||
# Deploying
|
# 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
|
## Tools
|
||||||
|
|
||||||
This example deployment uses `ytt` from [Carvel](https://carvel.dev/) to template the YAML files.
|
This example deployment uses `ytt` from [Carvel](https://carvel.dev/) to template the YAML files.
|
||||||
|
Loading…
Reference in New Issue
Block a user