diff --git a/examples/tokenreview/main.go b/examples/tokenreview/main.go new file mode 100644 index 0000000000..9d84d015e2 --- /dev/null +++ b/examples/tokenreview/main.go @@ -0,0 +1,58 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "os" + + _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" + "sigs.k8s.io/controller-runtime/pkg/client/config" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/manager/signals" + "sigs.k8s.io/controller-runtime/pkg/webhook" +) + +func init() { + log.SetLogger(zap.New()) +} + +func main() { + entryLog := log.Log.WithName("entrypoint") + + // Setup a Manager + entryLog.Info("setting up manager") + mgr, err := manager.New(config.GetConfigOrDie(), manager.Options{}) + if err != nil { + entryLog.Error(err, "unable to set up overall controller manager") + os.Exit(1) + } + + // Setup webhooks + entryLog.Info("setting up webhook server") + hookServer := mgr.GetWebhookServer() + + entryLog.Info("registering webhooks to the webhook server") + hookServer.Register("/validate-v1-tokenreview", &webhook.Authentication{Handler: &authenticator{Client: mgr.GetClient()}}) + + entryLog.Info("starting manager") + if err := mgr.Start(signals.SetupSignalHandler()); err != nil { + entryLog.Error(err, "unable to run manager") + os.Exit(1) + } +} diff --git a/examples/tokenreview/tokenreview.go b/examples/tokenreview/tokenreview.go new file mode 100644 index 0000000000..44b307d9ef --- /dev/null +++ b/examples/tokenreview/tokenreview.go @@ -0,0 +1,51 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + + v1 "k8s.io/api/authentication/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "sigs.k8s.io/controller-runtime/pkg/webhook/authentication" +) + +// +kubebuilder:webhook:path=/validate-v1-pod,mutating=false,failurePolicy=fail,groups="",resources=pods,verbs=create;update,versions=v1,name=vpod.kb.io + +// authenticator validates tokenreviews +type authenticator struct { + Client client.Client + decoder *authentication.Decoder +} + +// authenticator admits a request by the token. +func (v *authenticator) Handle(ctx context.Context, req authentication.Request) authentication.Response { + if req.Spec.Token == "invalid" { + return authentication.Unauthenticated("invalid is an invalid token", v1.UserInfo{}) + } + return authentication.Authenticated("", v1.UserInfo{}) +} + +// authenticator implements authentication.DecoderInjector. +// A decoder will be automatically injected. + +// InjectDecoder injects the decoder. +func (v *authenticator) InjectDecoder(d *authentication.Decoder) error { + v.decoder = d + return nil +} diff --git a/pkg/webhook/alias.go b/pkg/webhook/alias.go index 276784efb2..23ff56f2d3 100644 --- a/pkg/webhook/alias.go +++ b/pkg/webhook/alias.go @@ -19,6 +19,7 @@ package webhook import ( "gomodules.xyz/jsonpatch/v2" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + "sigs.k8s.io/controller-runtime/pkg/webhook/authentication" ) // define some aliases for common bits of the webhook functionality @@ -54,6 +55,20 @@ type AdmissionHandler = admission.Handler // AdmissionDecoder knows how to decode objects from admission requests. type AdmissionDecoder = admission.Decoder +// AuthenticationRequest defines the input for an authentication handler. +// It contains the token & audiences from the client. +type AuthenticationRequest = authentication.Request + +// AuthenticationResponse is the output of an authentication handler. +// It contains a response indicating if a given operation is allowed. +type AuthenticationResponse = authentication.Response + +// Authentication is webhook suitable for registration with the server +type Authentication = authentication.Webhook + +// AuthenticationHandler knows how to process authentication requests. +type AuthenticationHandler = authentication.Handler + // JSONPatchOp represents a single JSONPatch patch operation. type JSONPatchOp = jsonpatch.Operation @@ -70,4 +85,13 @@ var ( // Errored indicates that an error occurred in the admission request. Errored = admission.Errored + + // Authenticated indicates that the token review should be allowed for the given reason. + Authenticated = authentication.Authenticated + + // Unauthenticated indicates that the token review should be unauthorized for the given reson. + Unauthenticated = authentication.Unauthenticated + + // AuthenticationErrored indicates that an error occurred in the token review + AuthenticationErrored = authentication.Errored ) diff --git a/pkg/webhook/authentication/authentication_suite_test.go b/pkg/webhook/authentication/authentication_suite_test.go new file mode 100644 index 0000000000..0988f81285 --- /dev/null +++ b/pkg/webhook/authentication/authentication_suite_test.go @@ -0,0 +1,40 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package authentication + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "sigs.k8s.io/controller-runtime/pkg/envtest/printer" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +func TestAuthenticationWebhook(t *testing.T) { + RegisterFailHandler(Fail) + suiteName := "Authentication Webhook Suite" + RunSpecsWithDefaultAndCustomReporters(t, suiteName, []Reporter{printer.NewlineReporter{}, printer.NewProwReporter(suiteName)}) +} + +var _ = BeforeSuite(func(done Done) { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + close(done) +}, 60) diff --git a/pkg/webhook/authentication/decoder.go b/pkg/webhook/authentication/decoder.go new file mode 100644 index 0000000000..27e3ed8b4a --- /dev/null +++ b/pkg/webhook/authentication/decoder.go @@ -0,0 +1,40 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package authentication + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" +) + +// Decoder knows how to decode the contents of a CRD version conversion +// request into a concrete object. +// TODO(droot): consider reusing decoder from admission pkg for this. +type Decoder struct { + codecs serializer.CodecFactory +} + +// NewDecoder creates a Decoder given the runtime.Scheme +func NewDecoder(scheme *runtime.Scheme) (*Decoder, error) { + return &Decoder{codecs: serializer.NewCodecFactory(scheme)}, nil +} + +// DecodeInto decodes the inlined object in the into the passed-in runtime.Object. +func (d *Decoder) DecodeInto(content []byte, into runtime.Object) error { + deserializer := d.codecs.UniversalDeserializer() + return runtime.DecodeInto(deserializer, content, into) +} diff --git a/pkg/webhook/authentication/doc.go b/pkg/webhook/authentication/doc.go new file mode 100644 index 0000000000..07af01a10a --- /dev/null +++ b/pkg/webhook/authentication/doc.go @@ -0,0 +1,28 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* +Package authentication provides implementation for authentication webhook and methods to implement authentication webhook handlers. + +See examples/authentication.go for an example of authentication webhooks. +*/ +package authentication + +import ( + logf "sigs.k8s.io/controller-runtime/pkg/internal/log" +) + +var log = logf.RuntimeLog.WithName("authentication") diff --git a/pkg/webhook/authentication/http.go b/pkg/webhook/authentication/http.go new file mode 100644 index 0000000000..6b14a1908b --- /dev/null +++ b/pkg/webhook/authentication/http.go @@ -0,0 +1,141 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package authentication + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + + v1 "k8s.io/api/authentication/v1" + "k8s.io/api/authentication/v1beta1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" +) + +var authenticationScheme = runtime.NewScheme() +var authenticationCodecs = serializer.NewCodecFactory(authenticationScheme) + +func init() { + utilruntime.Must(v1.AddToScheme(authenticationScheme)) + utilruntime.Must(v1beta1.AddToScheme(authenticationScheme)) +} + +var _ http.Handler = &Webhook{} + +func (wh *Webhook) ServeHTTP(w http.ResponseWriter, r *http.Request) { + var body []byte + var err error + ctx := r.Context() + if wh.WithContextFunc != nil { + ctx = wh.WithContextFunc(ctx, r) + } + + var reviewResponse Response + if r.Body != nil { + if body, err = ioutil.ReadAll(r.Body); err != nil { + wh.log.Error(err, "unable to read the body from the incoming request") + reviewResponse = Errored(err) + wh.writeResponse(w, reviewResponse) + return + } + } else { + err = errors.New("request body is empty") + reviewResponse = Errored(err) + wh.writeResponse(w, reviewResponse) + return + } + + // verify the content type is accurate + contentType := r.Header.Get("Content-Type") + if contentType != "application/json" { + err = fmt.Errorf("contentType=%s, expected application/json", contentType) + wh.log.Error(err, "unable to process a request with an unknown content type", "content type", contentType) + reviewResponse = Errored(err) + wh.writeResponse(w, reviewResponse) + return + } + + // Both v1 and v1beta1 TokenReview types are exactly the same, so the v1beta1 type can + // be decoded into the v1 type. However the runtime codec's decoder guesses which type to + // decode into by type name if an Object's TypeMeta isn't set. By setting TypeMeta of an + // unregistered type to the v1 GVK, the decoder will coerce a v1beta1 TokenReview to v1. + // The actual TokenReview GVK will be used to write a typed response in case the + // webhook config permits multiple versions, otherwise this response will fail. + req := Request{} + ar := unversionedTokenReview{} + // avoid an extra copy + ar.SetGroupVersionKind(v1.SchemeGroupVersion.WithKind("TokenReview")) + _, actualTokRevGVK, err := authenticationCodecs.UniversalDeserializer().Decode(body, nil, &ar) + if err != nil { + wh.log.Error(err, "unable to decode the request") + reviewResponse = Errored(err) + wh.writeResponse(w, reviewResponse) + return + } + wh.log.V(1).Info("received request", "UID", req.UID, "kind", req.Kind) + + // TODO: add panic-recovery for Handle + reviewResponse = wh.Handle(ctx, req) + wh.writeResponseTyped(w, reviewResponse, actualTokRevGVK) +} + +// writeResponse writes response to w generically, i.e. without encoding GVK information. +func (wh *Webhook) writeResponse(w io.Writer, response Response) { + wh.writeTokenResponse(w, response.TokenReview) +} + +// writeResponseTyped writes response to w with GVK set to tokRevGVK, which is necessary +// if multiple TokenReview versions are permitted by the webhook. +func (wh *Webhook) writeResponseTyped(w io.Writer, response Response, tokRevGVK *schema.GroupVersionKind) { + ar := response.TokenReview + + // Default to a v1 TokenReview, otherwise the API server may not recognize the request + // if multiple TokenReview versions are permitted by the webhook config. + if tokRevGVK == nil || *tokRevGVK == (schema.GroupVersionKind{}) { + ar.SetGroupVersionKind(v1.SchemeGroupVersion.WithKind("TokenReview")) + } else { + ar.SetGroupVersionKind(*tokRevGVK) + } + wh.writeTokenResponse(w, ar) +} + +// writeTokenResponse writes ar to w. +func (wh *Webhook) writeTokenResponse(w io.Writer, ar v1.TokenReview) { + err := json.NewEncoder(w).Encode(ar) + if err != nil { + wh.log.Error(err, "unable to encode the response") + wh.writeResponse(w, Errored(err)) + } else { + res := ar + if log := wh.log; log.V(1).Enabled() { + log.V(1).Info("wrote response", "UID", res.UID, "authenticated", res.Status.Authenticated) + } + } +} + +// unversionedTokenReview is used to decode both v1 and v1beta1 TokenReview types. +type unversionedTokenReview struct { + v1.TokenReview +} + +var _ runtime.Object = &unversionedTokenReview{} diff --git a/pkg/webhook/authentication/http_test.go b/pkg/webhook/authentication/http_test.go new file mode 100644 index 0000000000..23d6347e5e --- /dev/null +++ b/pkg/webhook/authentication/http_test.go @@ -0,0 +1,228 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package authentication + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + authenticationv1 "k8s.io/api/authentication/v1" + + logf "sigs.k8s.io/controller-runtime/pkg/internal/log" + "sigs.k8s.io/controller-runtime/pkg/runtime/inject" +) + +var _ = Describe("Authentication Webhooks", func() { + + const ( + gvkJSONv1 = `"kind":"TokenReview","apiVersion":"authentication.k8s.io/v1"` + gvkJSONv1beta1 = `"kind":"TokenReview","apiVersion":"authentication.k8s.io/v1beta1"` + ) + + Describe("HTTP Handler", func() { + var respRecorder *httptest.ResponseRecorder + webhook := &Webhook{ + Handler: nil, + } + BeforeEach(func() { + respRecorder = &httptest.ResponseRecorder{ + Body: bytes.NewBuffer(nil), + } + _, err := inject.LoggerInto(log.WithName("test-webhook"), webhook) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should return bad-request when given an empty body", func() { + req := &http.Request{Body: nil} + + expected := `{"metadata":{"creationTimestamp":null},"spec":{},"status":{"user":{},"error":"request body is empty"}} +` + webhook.ServeHTTP(respRecorder, req) + Expect(respRecorder.Body.String()).To(Equal(expected)) + }) + + It("should return bad-request when given the wrong content-type", func() { + req := &http.Request{ + Header: http.Header{"Content-Type": []string{"application/foo"}}, + Method: http.MethodPost, + Body: nopCloser{Reader: bytes.NewBuffer(nil)}, + } + + expected := `{"metadata":{"creationTimestamp":null},"spec":{},"status":{"user":{},"error":"contentType=application/foo, expected application/json"}} +` + webhook.ServeHTTP(respRecorder, req) + Expect(respRecorder.Body.String()).To(Equal(expected)) + }) + + It("should return bad-request when given an undecodable body", func() { + req := &http.Request{ + Header: http.Header{"Content-Type": []string{"application/json"}}, + Method: http.MethodPost, + Body: nopCloser{Reader: bytes.NewBufferString("{")}, + } + + expected := `{"metadata":{"creationTimestamp":null},"spec":{},"status":{"user":{},"error":"couldn't get version/kind; json parse error: unexpected end of JSON input"}} +` + webhook.ServeHTTP(respRecorder, req) + Expect(respRecorder.Body.String()).To(Equal(expected)) + }) + + It("should return the response given by the handler with version defaulted to v1", func() { + req := &http.Request{ + Header: http.Header{"Content-Type": []string{"application/json"}}, + Method: http.MethodPost, + Body: nopCloser{Reader: bytes.NewBufferString(`{"request":{}}`)}, + } + webhook := &Webhook{ + Handler: &fakeHandler{}, + log: logf.RuntimeLog.WithName("webhook"), + } + + expected := fmt.Sprintf(`{%s,"metadata":{"creationTimestamp":null},"spec":{},"status":{"authenticated":true,"user":{}}} +`, gvkJSONv1) + + webhook.ServeHTTP(respRecorder, req) + Expect(respRecorder.Body.String()).To(Equal(expected)) + }) + + It("should return the v1 response given by the handler", func() { + req := &http.Request{ + Header: http.Header{"Content-Type": []string{"application/json"}}, + Method: http.MethodPost, + Body: nopCloser{Reader: bytes.NewBufferString(fmt.Sprintf(`{%s}`, gvkJSONv1))}, + } + webhook := &Webhook{ + Handler: &fakeHandler{}, + log: logf.RuntimeLog.WithName("webhook"), + } + + expected := fmt.Sprintf(`{%s,"metadata":{"creationTimestamp":null},"spec":{},"status":{"authenticated":true,"user":{}}} +`, gvkJSONv1) + webhook.ServeHTTP(respRecorder, req) + Expect(respRecorder.Body.String()).To(Equal(expected)) + }) + + It("should return the v1beta1 response given by the handler", func() { + req := &http.Request{ + Header: http.Header{"Content-Type": []string{"application/json"}}, + Method: http.MethodPost, + Body: nopCloser{Reader: bytes.NewBufferString(fmt.Sprintf(`{%s}`, gvkJSONv1beta1))}, + } + webhook := &Webhook{ + Handler: &fakeHandler{}, + log: logf.RuntimeLog.WithName("webhook"), + } + + expected := fmt.Sprintf(`{%s,"metadata":{"creationTimestamp":null},"spec":{},"status":{"authenticated":true,"user":{}}} +`, gvkJSONv1beta1) + webhook.ServeHTTP(respRecorder, req) + Expect(respRecorder.Body.String()).To(Equal(expected)) + }) + + It("should present the Context from the HTTP request, if any", func() { + req := &http.Request{ + Header: http.Header{"Content-Type": []string{"application/json"}}, + Method: http.MethodPost, + Body: nopCloser{Reader: bytes.NewBufferString(`{"request":{}}`)}, + } + type ctxkey int + const key ctxkey = 1 + const value = "from-ctx" + webhook := &Webhook{ + Handler: &fakeHandler{ + fn: func(ctx context.Context, req Request) Response { + <-ctx.Done() + return Authenticated(ctx.Value(key).(string), authenticationv1.UserInfo{}) + }, + }, + log: logf.RuntimeLog.WithName("webhook"), + } + + expected := fmt.Sprintf(`{%s,"metadata":{"creationTimestamp":null},"spec":{},"status":{"authenticated":true,"user":{},"error":%q}} +`, gvkJSONv1, value) + + ctx, cancel := context.WithCancel(context.WithValue(context.Background(), key, value)) + cancel() + webhook.ServeHTTP(respRecorder, req.WithContext(ctx)) + Expect(respRecorder.Body.String()).To(Equal(expected)) + }) + + It("should mutate the Context from the HTTP request, if func supplied", func() { + req := &http.Request{ + Header: http.Header{"Content-Type": []string{"application/json"}}, + Method: http.MethodPost, + Body: nopCloser{Reader: bytes.NewBufferString(`{"request":{}}`)}, + } + type ctxkey int + const key ctxkey = 1 + webhook := &Webhook{ + Handler: &fakeHandler{ + fn: func(ctx context.Context, req Request) Response { + return Authenticated(ctx.Value(key).(string), authenticationv1.UserInfo{}) + }, + }, + WithContextFunc: func(ctx context.Context, r *http.Request) context.Context { + return context.WithValue(ctx, key, r.Header["Content-Type"][0]) + }, + log: logf.RuntimeLog.WithName("webhook"), + } + + expected := fmt.Sprintf(`{%s,"metadata":{"creationTimestamp":null},"spec":{},"status":{"authenticated":true,"user":{},"error":%q}} +`, gvkJSONv1, "application/json") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + webhook.ServeHTTP(respRecorder, req.WithContext(ctx)) + Expect(respRecorder.Body.String()).To(Equal(expected)) + }) + }) +}) + +type nopCloser struct { + io.Reader +} + +func (nopCloser) Close() error { return nil } + +type fakeHandler struct { + invoked bool + fn func(context.Context, Request) Response + injectedString string +} + +func (h *fakeHandler) InjectString(s string) error { + h.injectedString = s + return nil +} + +func (h *fakeHandler) Handle(ctx context.Context, req Request) Response { + h.invoked = true + if h.fn != nil { + return h.fn(ctx, req) + } + return Response{TokenReview: authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + Authenticated: true, + }, + }} +} diff --git a/pkg/webhook/authentication/inject.go b/pkg/webhook/authentication/inject.go new file mode 100644 index 0000000000..b9586e7c8e --- /dev/null +++ b/pkg/webhook/authentication/inject.go @@ -0,0 +1,31 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package authentication + +// DecoderInjector is used by the ControllerManager to inject decoder into webhook handlers. +type DecoderInjector interface { + InjectDecoder(*Decoder) error +} + +// InjectDecoderInto will set decoder on i and return the result if it implements Decoder. Returns +// false if i does not implement Decoder. +func InjectDecoderInto(decoder *Decoder, i interface{}) (bool, error) { + if s, ok := i.(DecoderInjector); ok { + return true, s.InjectDecoder(decoder) + } + return false, nil +} diff --git a/pkg/webhook/authentication/response.go b/pkg/webhook/authentication/response.go new file mode 100644 index 0000000000..1393b416ff --- /dev/null +++ b/pkg/webhook/authentication/response.go @@ -0,0 +1,63 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package authentication + +import ( + authenticationv1 "k8s.io/api/authentication/v1" +) + +// Authenticated constructs a response indicating that the given token +// is valid. +func Authenticated(reason string, user authenticationv1.UserInfo) Response { + return ValidationResponse(true, user, reason) +} + +// Unauthenticated constructs a response indicating that the given token +// is not valid. +func Unauthenticated(reason string, user authenticationv1.UserInfo) Response { + return ValidationResponse(false, authenticationv1.UserInfo{}, reason) +} + +// Errored creates a new Response for error-handling a request. +func Errored(err error) Response { + return Response{ + TokenReview: authenticationv1.TokenReview{ + Spec: authenticationv1.TokenReviewSpec{}, + Status: authenticationv1.TokenReviewStatus{ + Authenticated: false, + Error: err.Error(), + }, + }, + } +} + +// ValidationResponse returns a response for admitting a request. +func ValidationResponse(authenticated bool, user authenticationv1.UserInfo, err string, audiences ...string) Response { + resp := Response{ + TokenReview: authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + Authenticated: authenticated, + User: user, + Audiences: audiences, + }, + }, + } + if len(err) > 0 { + resp.TokenReview.Status.Error = err + } + return resp +} diff --git a/pkg/webhook/authentication/response_test.go b/pkg/webhook/authentication/response_test.go new file mode 100644 index 0000000000..c28fa4760b --- /dev/null +++ b/pkg/webhook/authentication/response_test.go @@ -0,0 +1,160 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package authentication + +import ( + "errors" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + authenticationv1 "k8s.io/api/authentication/v1" +) + +var _ = Describe("Authentication Webhook Response Helpers", func() { + Describe("Authenticated", func() { + It("should return an 'allowed' response", func() { + Expect(Authenticated("", authenticationv1.UserInfo{})).To(Equal( + Response{ + TokenReview: authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + Authenticated: true, + User: authenticationv1.UserInfo{}, + }, + }, + }, + )) + }) + + It("should populate a status with a reason when a reason is given", func() { + Expect(Authenticated("acceptable", authenticationv1.UserInfo{})).To(Equal( + Response{ + TokenReview: authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + Authenticated: true, + User: authenticationv1.UserInfo{}, + Error: "acceptable", + }, + }, + }, + )) + }) + }) + + Describe("Unauthenticated", func() { + It("should return a 'not allowed' response", func() { + Expect(Unauthenticated("", authenticationv1.UserInfo{})).To(Equal( + Response{ + TokenReview: authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + Authenticated: false, + User: authenticationv1.UserInfo{}, + Error: "", + }, + }, + }, + )) + }) + + It("should populate a status with a reason when a reason is given", func() { + Expect(Unauthenticated("UNACCEPTABLE!", authenticationv1.UserInfo{})).To(Equal( + Response{ + TokenReview: authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + Authenticated: false, + User: authenticationv1.UserInfo{}, + Error: "UNACCEPTABLE!", + }, + }, + }, + )) + }) + }) + + Describe("Errored", func() { + It("should return a unauthenticated response with an error", func() { + err := errors.New("this is an error") + expected := Response{ + TokenReview: authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + Authenticated: false, + User: authenticationv1.UserInfo{}, + Error: err.Error(), + }, + }, + } + resp := Errored(err) + Expect(resp).To(Equal(expected)) + }) + }) + + Describe("ValidationResponse", func() { + It("should populate a status with a Error when a reason is given", func() { + By("checking that a message is populated for 'allowed' responses") + Expect(ValidationResponse(true, authenticationv1.UserInfo{}, "acceptable")).To(Equal( + Response{ + TokenReview: authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + Authenticated: true, + User: authenticationv1.UserInfo{}, + Error: "acceptable", + }, + }, + }, + )) + + By("checking that a message is populated for 'Unauthenticated' responses") + Expect(ValidationResponse(false, authenticationv1.UserInfo{}, "UNACCEPTABLE!")).To(Equal( + Response{ + TokenReview: authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + Authenticated: false, + User: authenticationv1.UserInfo{}, + Error: "UNACCEPTABLE!", + }, + }, + }, + )) + }) + + It("should return an authentication decision", func() { + By("checking that it returns an 'allowed' response when allowed is true") + Expect(ValidationResponse(true, authenticationv1.UserInfo{}, "")).To(Equal( + Response{ + TokenReview: authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + Authenticated: true, + User: authenticationv1.UserInfo{}, + }, + }, + }, + )) + + By("checking that it returns an 'Unauthenticated' response when allowed is false") + Expect(ValidationResponse(false, authenticationv1.UserInfo{}, "")).To(Equal( + Response{ + TokenReview: authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + Authenticated: false, + User: authenticationv1.UserInfo{}, + }, + }, + }, + )) + }) + }) +}) diff --git a/pkg/webhook/authentication/webhook.go b/pkg/webhook/authentication/webhook.go new file mode 100644 index 0000000000..0f9d46ee31 --- /dev/null +++ b/pkg/webhook/authentication/webhook.go @@ -0,0 +1,168 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package authentication + +import ( + "context" + "errors" + "net/http" + + "github.com/go-logr/logr" + authenticationv1 "k8s.io/api/authentication/v1" + "k8s.io/apimachinery/pkg/runtime" + + "sigs.k8s.io/controller-runtime/pkg/runtime/inject" +) + +var ( + errUnableToEncodeResponse = errors.New("unable to encode response") +) + +// Request defines the input for an authentication handler. +// It contains information to identify the object in +// question (group, version, kind, resource, subresource, +// name, namespace), as well as the operation in question +// (e.g. Get, Create, etc), and the object itself. +type Request struct { + authenticationv1.TokenReview +} + +// Response is the output of an authentication handler. +// It contains a response indicating if a given +// operation is allowed +type Response struct { + authenticationv1.TokenReview +} + +// Complete populates any fields that are yet to be set in +// the underlying TokenResponse, It mutates the response. +func (r *Response) Complete(req Request) error { + r.UID = req.UID + + return nil +} + +// Handler can handle an TokenReview. +type Handler interface { + // Handle yields a response to an TokenReview. + // + // The supplied context is extracted from the received http.Request, allowing wrapping + // http.Handlers to inject values into and control cancelation of downstream request processing. + Handle(context.Context, Request) Response +} + +// HandlerFunc implements Handler interface using a single function. +type HandlerFunc func(context.Context, Request) Response + +var _ Handler = HandlerFunc(nil) + +// Handle process the TokenReview by invoking the underlying function. +func (f HandlerFunc) Handle(ctx context.Context, req Request) Response { + return f(ctx, req) +} + +// Webhook represents each individual webhook. +type Webhook struct { + // Handler actually processes an authentication request returning whether it was authenticated or unauthenticated, + // and potentially patches to apply to the handler. + Handler Handler + + // WithContextFunc will allow you to take the http.Request.Context() and + // add any additional information such as passing the request path or + // headers thus allowing you to read them from within the handler + WithContextFunc func(context.Context, *http.Request) context.Context + + // decoder is constructed on receiving a scheme and passed down to then handler + decoder *Decoder + + log logr.Logger +} + +// InjectLogger gets a handle to a logging instance, hopefully with more info about this particular webhook. +func (w *Webhook) InjectLogger(l logr.Logger) error { + w.log = l + return nil +} + +// Handle processes TokenReview. +func (w *Webhook) Handle(ctx context.Context, req Request) Response { + resp := w.Handler.Handle(ctx, req) + if err := resp.Complete(req); err != nil { + w.log.Error(err, "unable to encode response") + return Errored(errUnableToEncodeResponse) + } + + return resp +} + +// InjectScheme injects a scheme into the webhook, in order to construct a Decoder. +func (w *Webhook) InjectScheme(s *runtime.Scheme) error { + // TODO(directxman12): we should have a better way to pass this down + + var err error + w.decoder, err = NewDecoder(s) + if err != nil { + return err + } + + // inject the decoder here too, just in case the order of calling this is not + // scheme first, then inject func + if w.Handler != nil { + if _, err := InjectDecoderInto(w.GetDecoder(), w.Handler); err != nil { + return err + } + } + + return nil +} + +// GetDecoder returns a decoder to decode the objects embedded in admission requests. +// It may be nil if we haven't received a scheme to use to determine object types yet. +func (w *Webhook) GetDecoder() *Decoder { + return w.decoder +} + +// InjectFunc injects the field setter into the webhook. +func (w *Webhook) InjectFunc(f inject.Func) error { + // inject directly into the handlers. It would be more correct + // to do this in a sync.Once in Handle (since we don't have some + // other start/finalize-type method), but it's more efficient to + // do it here, presumably. + + // also inject a decoder, and wrap this so that we get a setFields + // that injects a decoder (hopefully things don't ignore the duplicate + // InjectorInto call). + + var setFields inject.Func + setFields = func(target interface{}) error { + if err := f(target); err != nil { + return err + } + + if _, err := inject.InjectorInto(setFields, target); err != nil { + return err + } + + if _, err := InjectDecoderInto(w.GetDecoder(), target); err != nil { + return err + } + + return nil + } + + return setFields(w.Handler) +} diff --git a/pkg/webhook/authentication/webhook_test.go b/pkg/webhook/authentication/webhook_test.go new file mode 100644 index 0000000000..55849ece32 --- /dev/null +++ b/pkg/webhook/authentication/webhook_test.go @@ -0,0 +1,140 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package authentication + +import ( + "context" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + authenticationv1 "k8s.io/api/authentication/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + machinerytypes "k8s.io/apimachinery/pkg/types" + + logf "sigs.k8s.io/controller-runtime/pkg/internal/log" + "sigs.k8s.io/controller-runtime/pkg/runtime/inject" +) + +var _ = Describe("Authentication Webhooks", func() { + allowHandler := func() *Webhook { + handler := &fakeHandler{ + fn: func(ctx context.Context, req Request) Response { + return Response{ + TokenReview: authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + Authenticated: true, + }, + }, + } + }, + } + webhook := &Webhook{ + Handler: handler, + log: logf.RuntimeLog.WithName("webhook"), + } + + return webhook + } + + It("should invoke the handler to get a response", func() { + By("setting up a webhook with an allow handler") + webhook := allowHandler() + + By("invoking the webhook") + resp := webhook.Handle(context.Background(), Request{}) + + By("checking that it allowed the request") + Expect(resp.Status.Authenticated).To(BeTrue()) + }) + + It("should ensure that the response's UID is set to the request's UID", func() { + By("setting up a webhook") + webhook := allowHandler() + + By("invoking the webhook") + resp := webhook.Handle(context.Background(), Request{TokenReview: authenticationv1.TokenReview{ObjectMeta: metav1.ObjectMeta{UID: "foobar"}}}) + + By("checking that the response share's the request's UID") + Expect(resp.UID).To(Equal(machinerytypes.UID("foobar"))) + }) + + It("should populate the status on a response if one is not provided", func() { + By("setting up a webhook") + webhook := allowHandler() + + By("invoking the webhook") + resp := webhook.Handle(context.Background(), Request{}) + + By("checking that the response share's the request's UID") + Expect(resp.Status).To(Equal(authenticationv1.TokenReviewStatus{Authenticated: true})) + }) + + It("shouldn't overwrite the status on a response", func() { + By("setting up a webhook that sets a status") + webhook := &Webhook{ + Handler: HandlerFunc(func(ctx context.Context, req Request) Response { + return Response{ + TokenReview: authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + Authenticated: true, + Error: "Ground Control to Major Tom", + }, + }, + } + }), + log: logf.RuntimeLog.WithName("webhook"), + } + + By("invoking the webhook") + resp := webhook.Handle(context.Background(), Request{}) + + By("checking that the message is intact") + Expect(resp.Status).NotTo(BeNil()) + Expect(resp.Status.Authenticated).To(BeTrue()) + Expect(resp.Status.Error).To(Equal("Ground Control to Major Tom")) + }) + + Describe("dependency injection", func() { + It("should set dependencies passed in on the handler", func() { + By("setting up a webhook and injecting it with a injection func that injects a string") + setFields := func(target interface{}) error { + inj, ok := target.(stringInjector) + if !ok { + return nil + } + + return inj.InjectString("something") + } + handler := &fakeHandler{} + webhook := &Webhook{ + Handler: handler, + log: logf.RuntimeLog.WithName("webhook"), + } + Expect(setFields(webhook)).To(Succeed()) + Expect(inject.InjectorInto(setFields, webhook)).To(BeTrue()) + + By("checking that the string was injected") + Expect(handler.injectedString).To(Equal("something")) + }) + + }) +}) + +type stringInjector interface { + InjectString(s string) error +}