From ad863b2053184e25f6035745720864d57d6a1c3c Mon Sep 17 00:00:00 2001 From: Gordon Bleux Date: Fri, 28 Jan 2022 21:30:02 +0100 Subject: [PATCH] add support for v1.TokenReview DTOs. add flag to parse and respond with v1 DTOs. closes #47 --- cmd/kubehook/kubehook.go | 7 +- handlers/authenticate/authenticate.go | 58 ++++------- handlers/authenticate/authenticate_test.go | 17 +++- handlers/authenticate/beta1_processor.go | 95 ++++++++++++++++++ handlers/authenticate/beta1_processor_test.go | 97 +++++++++++++++++++ handlers/authenticate/processor.go | 29 ++++++ handlers/authenticate/release_processor.go | 95 ++++++++++++++++++ .../authenticate/release_processor_test.go | 97 +++++++++++++++++++ 8 files changed, 448 insertions(+), 47 deletions(-) create mode 100644 handlers/authenticate/beta1_processor.go create mode 100644 handlers/authenticate/beta1_processor_test.go create mode 100644 handlers/authenticate/processor.go create mode 100644 handlers/authenticate/release_processor.go create mode 100644 handlers/authenticate/release_processor_test.go diff --git a/cmd/kubehook/kubehook.go b/cmd/kubehook/kubehook.go index 3df13f4..39adbf6 100644 --- a/cmd/kubehook/kubehook.go +++ b/cmd/kubehook/kubehook.go @@ -42,6 +42,8 @@ import ( "github.com/rakyll/statik/fs" "go.uber.org/zap" kingpin "gopkg.in/alecthomas/kingpin.v2" + "k8s.io/api/authentication/v1beta1" + v1release "k8s.io/api/authentication/v1" ) const indexPath = "/index.html" @@ -126,6 +128,9 @@ func main() { clientCASubject = app.Flag("client-ca-subject", "If set, requires that the client CA matches the provided subject (requires --client-ca).").String() tlsCert = app.Flag("tls-cert", "If set, enables TLS and specifies the path to TLS certificate to use for HTTPS server (requires --tls-key).").ExistingFile() tlsKey = app.Flag("tls-key", "Path to TLS key to use for HTTPS server (requires --tls-cert).").ExistingFile() + tokenVersion = app.Flag("authentication-token-webhook-version", "The API version of the authentication.k8s.io TokenReview to expect from and respond to the api-server."). + Default(v1beta1.SchemeGroupVersion.Version). + Enum(v1beta1.SchemeGroupVersion.Version, v1release.SchemeGroupVersion.Version) secret = app.Arg("secret", "Secret for JWT HMAC signature and verification.").Required().Envar(envVarName(app.Name, "secret")).String() ) @@ -185,7 +190,7 @@ func main() { r.ServeFiles("/dist/*filepath", frontend) r.HandlerFunc("GET", "/", handlers.Content(index, filepath.Base(indexPath))) r.HandlerFunc("POST", "/generate", generate.Handler(m, h)) - r.HandlerFunc("POST", "/authenticate", authenticate.Handler(m)) + r.HandlerFunc("POST", "/authenticate", authenticate.Handler(m, *tokenVersion)) r.HandlerFunc("GET", "/quitquitquit", handlers.Run(shutdown)) r.HandlerFunc("GET", "/healthz", handlers.Ping()) diff --git a/handlers/authenticate/authenticate.go b/handlers/authenticate/authenticate.go index 93e8c81..95c9f60 100644 --- a/handlers/authenticate/authenticate.go +++ b/handlers/authenticate/authenticate.go @@ -18,76 +18,52 @@ package authenticate import ( "encoding/json" - "io" "net/http" "k8s.io/api/authentication/v1beta1" "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/pkg/errors" "github.com/planetlabs/kubehook/auth" ) const ( - authv1Beta1 = "authentication.k8s.io/v1beta1" tokenReview = "TokenReview" ) +type timeProvider func() v1.Time + // Handler returns an HTTP handler function that handles an authentication // webhook using the supplied Authenticator. -func Handler(a auth.Authenticator) http.HandlerFunc { +func Handler(a auth.Authenticator, tokenVersion string) http.HandlerFunc { + var proc processor + + if tokenVersion == v1beta1.SchemeGroupVersion.Version { + proc = newBeta1Processor(v1.Now) + } else { + proc = newReleaseProcessor(v1.Now) + } + return func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() - t, err := extractToken(r.Body) + t, err := proc.ExtractToken(r.Body) if err != nil { - write(w, v1beta1.TokenReviewStatus{Error: err.Error()}, http.StatusBadRequest) + write(w, proc.CreateErrorStatus(err), http.StatusBadRequest) return } u, err := a.Authenticate(t) if err != nil { - write(w, v1beta1.TokenReviewStatus{Error: err.Error()}, http.StatusForbidden) + write(w, proc.CreateErrorStatus(err), http.StatusForbidden) return } - write(w, tokenReviewStatus(u), http.StatusOK) - } -} - -func extractToken(b io.Reader) (string, error) { - req := &v1beta1.TokenReview{} - err := json.NewDecoder(b).Decode(req) - switch { - case err != nil: - return "", errors.Wrap(err, "cannot parse token request") - case req.APIVersion != authv1Beta1: - return "", errors.Errorf("unsupported API version %s", req.APIVersion) - case req.Kind != tokenReview: - return "", errors.Errorf("unsupported Kind %s", req.Kind) - case req.Spec.Token == "": - return "", errors.New("missing token") + write(w, proc.CreateReviewStatus(u), http.StatusOK) } - return req.Spec.Token, nil } -func write(w http.ResponseWriter, trStatus v1beta1.TokenReviewStatus, httpStatus int) { +func write(w http.ResponseWriter, data interface{}, httpStatus int) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(httpStatus) - json.NewEncoder(w).Encode(v1beta1.TokenReview{ // nolint: gosec - TypeMeta: v1.TypeMeta{APIVersion: authv1Beta1, Kind: tokenReview}, - ObjectMeta: v1.ObjectMeta{CreationTimestamp: v1.Now()}, - Status: trStatus, - }) -} - -func tokenReviewStatus(u *auth.User) v1beta1.TokenReviewStatus { - return v1beta1.TokenReviewStatus{ - Authenticated: true, - User: v1beta1.UserInfo{ - Username: u.Username, - UID: u.UID, - Groups: u.Groups, - }, - } + json.NewEncoder(w).Encode(data) // nolint: gosec } diff --git a/handlers/authenticate/authenticate_test.go b/handlers/authenticate/authenticate_test.go index 382396d..3974ccd 100644 --- a/handlers/authenticate/authenticate_test.go +++ b/handlers/authenticate/authenticate_test.go @@ -22,6 +22,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/go-test/deep" "github.com/pkg/errors" @@ -31,6 +32,12 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1" ) +func mockTimeProvider() v1.Time { + now := time.Unix(0, 0) + + return v1.Time{now} +} + type predictableAuthenticator struct { err error } @@ -66,7 +73,7 @@ func TestHandler(t *testing.T) { { name: "Success", req: &v1beta1.TokenReview{ - TypeMeta: v1.TypeMeta{APIVersion: authv1Beta1, Kind: tokenReview}, + TypeMeta: v1.TypeMeta{APIVersion: beta1APIVersion, Kind: tokenReview}, ObjectMeta: v1.ObjectMeta{CreationTimestamp: v1.Now()}, Spec: v1beta1.TokenReviewSpec{Token: "token"}, }, @@ -80,7 +87,7 @@ func TestHandler(t *testing.T) { name: "AuthFailed", err: errors.New("bad token"), req: &v1beta1.TokenReview{ - TypeMeta: v1.TypeMeta{APIVersion: authv1Beta1, Kind: tokenReview}, + TypeMeta: v1.TypeMeta{APIVersion: beta1APIVersion, Kind: tokenReview}, ObjectMeta: v1.ObjectMeta{CreationTimestamp: v1.Now()}, Spec: v1beta1.TokenReviewSpec{Token: "badToken"}, }, @@ -100,7 +107,7 @@ func TestHandler(t *testing.T) { { name: "BadKind", req: &v1beta1.TokenReview{ - TypeMeta: v1.TypeMeta{APIVersion: authv1Beta1, Kind: "TokenRequest"}, + TypeMeta: v1.TypeMeta{APIVersion: beta1APIVersion, Kind: "TokenRequest"}, ObjectMeta: v1.ObjectMeta{CreationTimestamp: v1.Now()}, Spec: v1beta1.TokenReviewSpec{Token: "badToken"}, }, @@ -110,7 +117,7 @@ func TestHandler(t *testing.T) { { name: "MissingToken", req: &v1beta1.TokenReview{ - TypeMeta: v1.TypeMeta{APIVersion: authv1Beta1, Kind: tokenReview}, + TypeMeta: v1.TypeMeta{APIVersion: beta1APIVersion, Kind: tokenReview}, ObjectMeta: v1.ObjectMeta{CreationTimestamp: v1.Now()}, Spec: v1beta1.TokenReviewSpec{Token: ""}, }, @@ -127,7 +134,7 @@ func TestHandler(t *testing.T) { if err != nil { t.Fatalf("json.Marshal(%+#v): %v", tt.req, err) } - Handler(a)(w, httptest.NewRequest("GET", "/", bytes.NewReader(body))) + Handler(a, v1beta1.SchemeGroupVersion.Version)(w, httptest.NewRequest("GET", "/", bytes.NewReader(body))) if w.Code != tt.httpStatus { t.Fatalf("w.Code: want %v, got %v", tt.httpStatus, w.Code) diff --git a/handlers/authenticate/beta1_processor.go b/handlers/authenticate/beta1_processor.go new file mode 100644 index 0000000..a3dccbe --- /dev/null +++ b/handlers/authenticate/beta1_processor.go @@ -0,0 +1,95 @@ +/* +Copyright 2018 Planet Labs Inc. + +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 authenticate + +import ( + "encoding/json" + "io" + + "github.com/pkg/errors" + "k8s.io/api/authentication/v1beta1" + "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/planetlabs/kubehook/auth" +) + +var beta1APIVersion = v1beta1.SchemeGroupVersion.String() + +type beta1Processor struct { + APIVersion string + Now timeProvider +} + +func (p *beta1Processor) ExtractToken(b io.Reader) (string, error) { + req := &v1beta1.TokenReview{} + err := json.NewDecoder(b).Decode(req) + + switch { + case err != nil: + return "", errors.Wrap(err, "cannot parse token request") + case req.APIVersion != p.APIVersion: + return "", errors.Errorf("unsupported API version %s", req.APIVersion) + case req.Kind != tokenReview: + return "", errors.Errorf("unsupported Kind %s", req.Kind) + case req.Spec.Token == "": + return "", errors.New("missing token") + } + + return req.Spec.Token, nil +} + +func (p *beta1Processor) CreateErrorStatus(err error) interface{} { + review := p.newTokenReview() + + review.Status = v1beta1.TokenReviewStatus{Error: err.Error()} + + return review +} + +func (p *beta1Processor) CreateReviewStatus(u *auth.User) interface{} { + review := p.newTokenReview() + + review.Status = v1beta1.TokenReviewStatus{ + Authenticated: true, + User: v1beta1.UserInfo{ + Username: u.Username, + UID: u.UID, + Groups: u.Groups, + }, + } + + return review +} + +func (p *beta1Processor) newTokenReview() v1beta1.TokenReview { + return v1beta1.TokenReview{ + TypeMeta: v1.TypeMeta{ + APIVersion: p.APIVersion, + Kind: tokenReview, + }, + ObjectMeta: v1.ObjectMeta{CreationTimestamp: p.Now()}, + } +} + +func newBeta1Processor(now timeProvider) *beta1Processor { + result := &beta1Processor{ + APIVersion: beta1APIVersion, + Now: now, + } + + return result +} diff --git a/handlers/authenticate/beta1_processor_test.go b/handlers/authenticate/beta1_processor_test.go new file mode 100644 index 0000000..ec77500 --- /dev/null +++ b/handlers/authenticate/beta1_processor_test.go @@ -0,0 +1,97 @@ +/* +Copyright 2018 Planet Labs Inc. + +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 authenticate + +import ( + "strings" + "testing" +) + +func TestBeta1ProcessorExtractToken(t *testing.T) { + sut := newBeta1Processor(mockTimeProvider) + cases := map[string]struct { + input string + wantResult string + wantError bool + }{ + "success": { + input: `{ + "kind": "TokenReview", + "apiVersion": "authentication.k8s.io/v1beta1", + "spec": { + "token": "beta1:password" + } +}`, + wantResult: "beta1:password", + }, + "wrong DTO": { + input: `{ + "error": "marshal failure" +}`, + wantError: true, + }, + "wrong kind": { + input: `{ + "kind": "NotATokenReview", + "apiVersion": "authentication.k8s.io/v1beta1", + "spec": { + "token": "beta1:password" + } +}`, + wantError: true, + }, + "wrong API version": { + input: `{ + "kind": "TokenReview", + "apiVersion": "authentication.k8s.io/v1", + "spec": { + "token": "beta1:password" + } +}`, + wantError: true, + }, + "empty token": { + input: `{ + "kind": "TokenReview", + "apiVersion": "authentication.k8s.io/v1beta1", + "spec": { + "token": "" + } +}`, + wantError: true, + }, + } + + for name, tt := range cases { + t.Run(name, func(t *testing.T) { + input := strings.NewReader(tt.input) + token, err := sut.ExtractToken(input) + + if err != nil { + if tt.wantError { + return + } + + t.Fatalf("p.ExtractToken(...): %v", err) + } + + if token != tt.wantResult { + t.Errorf("got = %v; want = %v", token, tt.wantResult) + } + }) + } +} diff --git a/handlers/authenticate/processor.go b/handlers/authenticate/processor.go new file mode 100644 index 0000000..f893a5f --- /dev/null +++ b/handlers/authenticate/processor.go @@ -0,0 +1,29 @@ +/* +Copyright 2018 Planet Labs Inc. + +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 authenticate + +import ( + "io" + + "github.com/planetlabs/kubehook/auth" +) + +type processor interface { + ExtractToken(b io.Reader) (string, error) + CreateErrorStatus(err error) interface{} + CreateReviewStatus(u *auth.User) interface{} +} diff --git a/handlers/authenticate/release_processor.go b/handlers/authenticate/release_processor.go new file mode 100644 index 0000000..e67b095 --- /dev/null +++ b/handlers/authenticate/release_processor.go @@ -0,0 +1,95 @@ +/* +Copyright 2018 Planet Labs Inc. + +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 authenticate + +import ( + "encoding/json" + "io" + + "github.com/pkg/errors" + v1release "k8s.io/api/authentication/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/planetlabs/kubehook/auth" +) + +var releaseAPIVersion = v1release.SchemeGroupVersion.String() + +type releaseProcessor struct { + APIVersion string + Now timeProvider +} + +func (p *releaseProcessor) ExtractToken(b io.Reader) (string, error) { + req := &v1release.TokenReview{} + err := json.NewDecoder(b).Decode(req) + + switch { + case err != nil: + return "", errors.Wrap(err, "cannot parse token request") + case req.APIVersion != p.APIVersion: + return "", errors.Errorf("unsupported API version %s", req.APIVersion) + case req.Kind != tokenReview: + return "", errors.Errorf("unsupported Kind %s", req.Kind) + case req.Spec.Token == "": + return "", errors.New("missing token") + } + + return req.Spec.Token, nil +} + +func (p *releaseProcessor) CreateErrorStatus(err error) interface{} { + review := p.newTokenReview() + + review.Status = v1release.TokenReviewStatus{Error: err.Error()} + + return review +} + +func (p *releaseProcessor) CreateReviewStatus(u *auth.User) interface{} { + review := p.newTokenReview() + + review.Status = v1release.TokenReviewStatus{ + Authenticated: true, + User: v1release.UserInfo{ + Username: u.Username, + UID: u.UID, + Groups: u.Groups, + }, + } + + return review +} + +func (p *releaseProcessor) newTokenReview() v1release.TokenReview { + return v1release.TokenReview{ + TypeMeta: v1.TypeMeta{ + APIVersion: p.APIVersion, + Kind: tokenReview, + }, + ObjectMeta: v1.ObjectMeta{CreationTimestamp: p.Now()}, + } +} + +func newReleaseProcessor(now timeProvider) *releaseProcessor { + result := &releaseProcessor{ + APIVersion: releaseAPIVersion, + Now: now, + } + + return result +} diff --git a/handlers/authenticate/release_processor_test.go b/handlers/authenticate/release_processor_test.go new file mode 100644 index 0000000..f11575c --- /dev/null +++ b/handlers/authenticate/release_processor_test.go @@ -0,0 +1,97 @@ +/* +Copyright 2018 Planet Labs Inc. + +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 authenticate + +import ( + "strings" + "testing" +) + +func TestReleaseProcessorExtractToken(t *testing.T) { + sut := newReleaseProcessor(mockTimeProvider) + cases := map[string]struct { + input string + wantResult string + wantError bool + }{ + "success": { + input: `{ + "kind": "TokenReview", + "apiVersion": "authentication.k8s.io/v1", + "spec": { + "token": "release:password" + } +}`, + wantResult: "release:password", + }, + "wrong DTO": { + input: `{ + "error": "marshal failure" +}`, + wantError: true, + }, + "wrong kind": { + input: `{ + "kind": "NotATokenReview", + "apiVersion": "authentication.k8s.io/v1", + "spec": { + "token": "release:password" + } +}`, + wantError: true, + }, + "wrong API version": { + input: `{ + "kind": "TokenReview", + "apiVersion": "authentication.k8s.io/v1beta1", + "spec": { + "token": "release:password" + } +}`, + wantError: true, + }, + "empty token": { + input: `{ + "kind": "TokenReview", + "apiVersion": "authentication.k8s.io/v1", + "spec": { + "token": "" + } +}`, + wantError: true, + }, + } + + for name, tt := range cases { + t.Run(name, func(t *testing.T) { + input := strings.NewReader(tt.input) + token, err := sut.ExtractToken(input) + + if err != nil { + if tt.wantError { + return + } + + t.Fatalf("p.ExtractToken(...): %v", err) + } + + if token != tt.wantResult { + t.Errorf("got = %v; want = %v", token, tt.wantResult) + } + }) + } +}